mirror of
				https://github.com/RGBCube/serenity
				synced 2025-10-31 08:32:43 +00:00 
			
		
		
		
	SoundPlayer: Add playlist supprt
And a M3U(8) parser
This commit is contained in:
		
							parent
							
								
									e4d6a56a28
								
							
						
					
					
						commit
						2e28b8ebcc
					
				
					 10 changed files with 586 additions and 57 deletions
				
			
		|  | @ -9,6 +9,8 @@ jpg=/bin/QuickShow | |||
| jpeg=/bin/QuickShow | ||||
| html=/bin/Browser | ||||
| wav=/bin/SoundPlayer | ||||
| m3u=/bin/SoundPlayer | ||||
| m3u8=/bin/SoundPlayer | ||||
| txt=/bin/TextEditor | ||||
| font=/bin/FontEditor | ||||
| sheets=/bin/Spreadsheet | ||||
|  |  | |||
|  | @ -7,6 +7,8 @@ set(SOURCES | |||
|     BarsVisualizationWidget.cpp | ||||
|     AudioAlgorithms.cpp | ||||
|     NoVisualizationWidget.cpp | ||||
|     M3UParser.cpp | ||||
|     PlaylistWidget.cpp | ||||
| ) | ||||
| 
 | ||||
| serenity_app(SoundPlayer ICON app-sound-player) | ||||
|  |  | |||
							
								
								
									
										130
									
								
								Userland/Applications/SoundPlayer/M3UParser.cpp
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								Userland/Applications/SoundPlayer/M3UParser.cpp
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,130 @@ | |||
| /*
 | ||||
|  * Copyright (c) 2021, Cesar Torres <shortanemoia@protonmail.com> | ||||
|  * All rights reserved. | ||||
|  * | ||||
|  * Redistribution and use in source and binary forms, with or without | ||||
|  * modification, are permitted provided that the following conditions are met: | ||||
|  * | ||||
|  * 1. Redistributions of source code must retain the above copyright notice, this | ||||
|  *    list of conditions and the following disclaimer. | ||||
|  * | ||||
|  * 2. Redistributions in binary form must reproduce the above copyright notice, | ||||
|  *    this list of conditions and the following disclaimer in the documentation | ||||
|  *    and/or other materials provided with the distribution. | ||||
|  * | ||||
|  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" | ||||
|  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE | ||||
|  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE | ||||
|  * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE | ||||
|  * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL | ||||
|  * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR | ||||
|  * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER | ||||
|  * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, | ||||
|  * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | ||||
|  * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | ||||
|  */ | ||||
| 
 | ||||
| #include "M3UParser.h" | ||||
| #include <AK/NonnullOwnPtrVector.h> | ||||
| #include <AK/OwnPtr.h> | ||||
| #include <AK/RefPtr.h> | ||||
| #include <AK/Utf8View.h> | ||||
| #include <LibCore/FileStream.h> | ||||
| #include <ctype.h> | ||||
| 
 | ||||
| M3UParser::M3UParser() | ||||
| { | ||||
| } | ||||
| 
 | ||||
| NonnullOwnPtr<M3UParser> M3UParser::from_file(const String path) | ||||
| { | ||||
| 
 | ||||
|     auto parser = make<M3UParser>(); | ||||
|     VERIFY(!path.is_null() && !path.is_empty() && !path.is_whitespace()); | ||||
|     parser->m_use_utf8 = path.ends_with(".m3u8", AK::CaseSensitivity::CaseInsensitive); | ||||
|     FILE* file = fopen(path.characters(), "r"); | ||||
|     VERIFY(file != nullptr); | ||||
|     fseek(file, 0, SEEK_END); | ||||
|     size_t file_size = ftell(file); | ||||
|     fseek(file, 0, SEEK_SET); | ||||
|     VERIFY(file_size > 0); | ||||
|     Vector<u8> temp_buffer; | ||||
|     temp_buffer.resize(file_size); | ||||
|     auto bytes_read = fread(temp_buffer.data(), 1, file_size, file); | ||||
|     VERIFY(bytes_read == file_size); | ||||
|     parser->m_m3u_raw_data = *new String(temp_buffer.span(), NoChomp); | ||||
|     return parser; | ||||
| } | ||||
| 
 | ||||
| NonnullOwnPtr<M3UParser> M3UParser::from_memory(const String& m3u_contents, bool utf8) | ||||
| { | ||||
|     auto parser = make<M3UParser>(); | ||||
|     VERIFY(!m3u_contents.is_null() && !m3u_contents.is_empty() && !m3u_contents.is_whitespace()); | ||||
|     parser->m_m3u_raw_data = m3u_contents; | ||||
|     parser->m_use_utf8 = utf8; | ||||
|     return parser; | ||||
| } | ||||
| 
 | ||||
| NonnullOwnPtr<Vector<M3UEntry>> M3UParser::parse(bool include_extended_info) | ||||
| { | ||||
|     auto vec = make<Vector<M3UEntry>>(); | ||||
| 
 | ||||
|     bool has_exteded_info_tag = false; | ||||
|     if (!m_use_utf8) { | ||||
|         auto lines = m_m3u_raw_data.split_view('\n'); | ||||
| 
 | ||||
|         if (include_extended_info) { | ||||
|             if (lines[0] == "#EXTM3U") | ||||
|                 has_exteded_info_tag = true; | ||||
|         } | ||||
| 
 | ||||
|         M3UExtendedInfo metadata_for_next_file {}; | ||||
|         for (auto& line : lines) { | ||||
|             line = line.trim_whitespace(); | ||||
|             M3UEntry entry {}; | ||||
|             if (line.starts_with('#') && has_exteded_info_tag) { | ||||
|                 if (line.starts_with("#EXTINF")) { | ||||
|                     auto data = line.substring_view(8); | ||||
|                     auto separator = data.find_first_of(','); | ||||
|                     VERIFY(separator.has_value()); | ||||
|                     auto seconds = data.substring_view(0, separator.value()); | ||||
|                     VERIFY(!seconds.is_whitespace() && !seconds.is_null() && !seconds.is_empty()); | ||||
|                     metadata_for_next_file.track_length_in_seconds = seconds.to_uint(); | ||||
|                     auto display_name = data.substring_view(seconds.length() + 1); | ||||
|                     VERIFY(!display_name.is_empty() && !display_name.is_null() && !display_name.is_empty()); | ||||
|                     metadata_for_next_file.track_display_title = display_name; | ||||
|                     //TODO: support the alternative, non-standard #EXTINF value of a key=value dictionary
 | ||||
|                 } else if (line.starts_with("#PLAYLIST")) { | ||||
|                     auto name = line.substring_view(10); | ||||
|                     VERIFY(!name.is_empty()); | ||||
|                     m_parsed_playlist_title = name; | ||||
|                 } else if (line.starts_with("#EXTGRP")) { | ||||
|                     auto name = line.substring_view(8); | ||||
|                     VERIFY(!name.is_empty()); | ||||
|                     metadata_for_next_file.group_name = name; | ||||
|                 } else if (line.starts_with("#EXTALB")) { | ||||
|                     auto name = line.substring_view(8); | ||||
|                     VERIFY(!name.is_empty()); | ||||
|                     metadata_for_next_file.album_title = name; | ||||
|                 } else if (line.starts_with("#EXTART")) { | ||||
|                     auto name = line.substring_view(8); | ||||
|                     VERIFY(!name.is_empty()); | ||||
|                     metadata_for_next_file.album_artist = name; | ||||
|                 } else if (line.starts_with("#EXTGENRE")) { | ||||
|                     auto name = line.substring_view(10); | ||||
|                     VERIFY(!name.is_empty()); | ||||
|                     metadata_for_next_file.album_genre = name; | ||||
|                 } | ||||
|                 //TODO: Support M3A files (M3U files with embedded mp3 files)
 | ||||
|             } else { | ||||
|                 entry.path = line; | ||||
|                 entry.extended_info = metadata_for_next_file; | ||||
|                 vec->append(entry); | ||||
|             } | ||||
|         } | ||||
|     } else { | ||||
|         //TODO: Implement M3U8 parsing
 | ||||
|         TODO(); | ||||
|     } | ||||
|     return vec; | ||||
| } | ||||
							
								
								
									
										69
									
								
								Userland/Applications/SoundPlayer/M3UParser.h
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								Userland/Applications/SoundPlayer/M3UParser.h
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,69 @@ | |||
| /*
 | ||||
|  * Copyright (c) 2021, Cesar Torres <shortanemoia@protonmail.com> | ||||
|  * All rights reserved. | ||||
|  * | ||||
|  * Redistribution and use in source and binary forms, with or without | ||||
|  * modification, are permitted provided that the following conditions are met: | ||||
|  * | ||||
|  * 1. Redistributions of source code must retain the above copyright notice, this | ||||
|  *    list of conditions and the following disclaimer. | ||||
|  * | ||||
|  * 2. Redistributions in binary form must reproduce the above copyright notice, | ||||
|  *    this list of conditions and the following disclaimer in the documentation | ||||
|  *    and/or other materials provided with the distribution. | ||||
|  * | ||||
|  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" | ||||
|  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE | ||||
|  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE | ||||
|  * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE | ||||
|  * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL | ||||
|  * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR | ||||
|  * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER | ||||
|  * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, | ||||
|  * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | ||||
|  * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | ||||
|  */ | ||||
| 
 | ||||
| #pragma once | ||||
| 
 | ||||
| #include <AK/Optional.h> | ||||
| #include <AK/RefPtr.h> | ||||
| #include <AK/String.h> | ||||
| #include <AK/StringView.h> | ||||
| #include <AK/Vector.h> | ||||
| 
 | ||||
| // Extended M3U fields (de facto standard)
 | ||||
| struct M3UExtendedInfo { | ||||
|     Optional<u32> track_length_in_seconds; | ||||
|     Optional<String> track_display_title; | ||||
|     Optional<String> group_name; | ||||
|     Optional<String> album_title; | ||||
|     Optional<String> album_artist; | ||||
|     Optional<String> album_genre; | ||||
|     Optional<u64> file_size_in_bytes; | ||||
|     Optional<ReadonlyBytes> embedded_mp3; | ||||
|     Optional<String> cover_path; | ||||
| }; | ||||
| 
 | ||||
| struct M3UEntry { | ||||
|     String path; | ||||
|     Optional<M3UExtendedInfo> extended_info; | ||||
| }; | ||||
| 
 | ||||
| class M3UParser { | ||||
| public: | ||||
|     static NonnullOwnPtr<M3UParser> from_file(String path); | ||||
|     static NonnullOwnPtr<M3UParser> from_memory(const String& m3u_contents, bool utf8); | ||||
| 
 | ||||
|     NonnullOwnPtr<Vector<M3UEntry>> parse(bool include_extended_info); | ||||
| 
 | ||||
|     Optional<String>& get_playlist_title_metadata() { return m_parsed_playlist_title; } | ||||
| 
 | ||||
|     M3UParser(); | ||||
| 
 | ||||
| private: | ||||
|     String m_m3u_raw_data; | ||||
|     String m_playlist_path; | ||||
|     bool m_use_utf8; | ||||
|     Optional<String> m_parsed_playlist_title; | ||||
| }; | ||||
|  | @ -27,6 +27,7 @@ | |||
| #pragma once | ||||
| 
 | ||||
| #include "PlaybackManager.h" | ||||
| #include "PlaylistWidget.h" | ||||
| #include "VisualizationBase.h" | ||||
| #include <AK/RefPtr.h> | ||||
| 
 | ||||
|  | @ -34,11 +35,13 @@ struct PlayerState { | |||
|     bool is_paused; | ||||
|     bool is_stopped; | ||||
|     bool has_loaded_file; | ||||
|     bool is_looping; | ||||
|     bool is_looping_file; | ||||
|     bool is_looping_playlist; | ||||
|     int loaded_file_samplerate; | ||||
|     double volume; | ||||
|     Audio::ClientConnection& connection; | ||||
|     PlaybackManager& manager; | ||||
|     StringView loaded_filename; | ||||
|     String loaded_filename; | ||||
| }; | ||||
| 
 | ||||
| class Player { | ||||
|  | @ -53,17 +56,27 @@ public: | |||
|     bool is_paused() const { return m_player_state.is_paused; } | ||||
|     bool has_loaded_file() const { return m_player_state.has_loaded_file; } | ||||
|     double volume() const { return m_player_state.volume; } | ||||
|     bool looping() const { return m_player_state.is_looping; } | ||||
|     StringView& loaded_filename() { return m_player_state.loaded_filename; } | ||||
|     bool looping() const { return m_player_state.is_looping_file; } | ||||
|     bool looping_playlist() const { return m_player_state.is_looping_playlist; } | ||||
|     const String& loaded_filename() { return m_player_state.loaded_filename; } | ||||
|     int loaded_file_samplerate() { return m_player_state.loaded_file_samplerate; } | ||||
| 
 | ||||
|     virtual void set_stopped(bool stopped) { m_player_state.is_stopped = stopped; } | ||||
|     virtual void set_paused(bool paused) { m_player_state.is_paused = paused; } | ||||
|     virtual void set_has_loaded_file(bool loaded) { m_player_state.has_loaded_file = loaded; } | ||||
|     virtual void set_volume(double volume) { m_player_state.volume = volume; } | ||||
|     virtual void set_looping(bool loop) | ||||
|     virtual void set_volume(double volume) | ||||
|     { | ||||
|         m_player_state.is_looping = loop; | ||||
|         manager().loop(loop); | ||||
|         m_player_state.volume = volume; | ||||
|         client_connection().set_main_mix_volume((double)(volume * 100)); | ||||
|     } | ||||
|     virtual void set_loaded_file_samplerate(int samplerate) { m_player_state.loaded_file_samplerate = samplerate; } | ||||
|     virtual void set_looping_file(bool loop) | ||||
|     { | ||||
|         m_player_state.is_looping_file = loop; | ||||
|     } | ||||
|     virtual void set_looping_playlist(bool loop) | ||||
|     { | ||||
|         m_player_state.is_looping_playlist = loop; | ||||
|     } | ||||
|     virtual void set_loaded_filename(StringView& filename) { m_player_state.loaded_filename = filename; } | ||||
| 
 | ||||
|  | @ -72,4 +85,5 @@ public: | |||
| 
 | ||||
| protected: | ||||
|     PlayerState m_player_state; | ||||
|     RefPtr<PlaylistModel> m_playlist_model; | ||||
| }; | ||||
|  |  | |||
							
								
								
									
										127
									
								
								Userland/Applications/SoundPlayer/PlaylistWidget.cpp
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								Userland/Applications/SoundPlayer/PlaylistWidget.cpp
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,127 @@ | |||
| /*
 | ||||
|  * Copyright (c) 2021, Cesar Torres <shortanemoia@protonmail.com> | ||||
|  * All rights reserved. | ||||
|  * | ||||
|  * Redistribution and use in source and binary forms, with or without | ||||
|  * modification, are permitted provided that the following conditions are met: | ||||
|  * | ||||
|  * 1. Redistributions of source code must retain the above copyright notice, this | ||||
|  *    list of conditions and the following disclaimer. | ||||
|  * | ||||
|  * 2. Redistributions in binary form must reproduce the above copyright notice, | ||||
|  *    this list of conditions and the following disclaimer in the documentation | ||||
|  *    and/or other materials provided with the distribution. | ||||
|  * | ||||
|  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" | ||||
|  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE | ||||
|  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE | ||||
|  * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE | ||||
|  * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL | ||||
|  * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR | ||||
|  * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER | ||||
|  * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, | ||||
|  * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | ||||
|  * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | ||||
|  */ | ||||
| 
 | ||||
| #include "PlaylistWidget.h" | ||||
| #include "Player.h" | ||||
| #include "SoundPlayerWidgetAdvancedView.h" | ||||
| #include <AK/LexicalPath.h> | ||||
| #include <LibGUI/BoxLayout.h> | ||||
| #include <LibGUI/HeaderView.h> | ||||
| #include <LibGUI/Model.h> | ||||
| #include <LibGUI/Window.h> | ||||
| 
 | ||||
| PlaylistWidget::PlaylistWidget() | ||||
| { | ||||
|     set_layout<GUI::VerticalBoxLayout>(); | ||||
|     m_table_view = add<PlaylistTableView>(); | ||||
|     m_table_view->set_selection_mode(GUI::AbstractView::SelectionMode::SingleSelection); | ||||
|     m_table_view->set_selection_behavior(GUI::AbstractView::SelectionBehavior::SelectRows); | ||||
|     m_table_view->set_highlight_selected_rows(true); | ||||
|     m_table_view->on_doubleclick = [&](const Gfx::Point<int>& point) { | ||||
|         auto player = dynamic_cast<Player*>(window()->main_widget()); | ||||
|         auto index = m_table_view->index_at_event_position(point); | ||||
|         if (!index.is_valid()) | ||||
|             return; | ||||
|         player->open_file(m_table_view->model()->data(index, static_cast<GUI::ModelRole>(PlaylistModelCustomRole::FilePath)).as_string()); | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
| GUI::Variant PlaylistModel::data(const GUI::ModelIndex& index, GUI::ModelRole role) const | ||||
| { | ||||
|     if (role == GUI::ModelRole::TextAlignment) | ||||
|         return "CenterLeft"; | ||||
|     if (role == GUI::ModelRole::Display) { | ||||
|         switch (index.column()) { | ||||
|         case 0: | ||||
|             return m_playlist_items[index.row()].extended_info->track_display_title.value_or(LexicalPath(m_playlist_items[index.row()].path).title()); | ||||
|         case 1: | ||||
|             return format_duration(m_playlist_items[index.row()].extended_info->track_length_in_seconds.value_or(0)); | ||||
|         case 2: | ||||
|             return m_playlist_items[index.row()].extended_info->group_name.value_or(""); | ||||
|         case 3: | ||||
|             return m_playlist_items[index.row()].extended_info->album_title.value_or(""); | ||||
|         case 4: | ||||
|             return m_playlist_items[index.row()].extended_info->album_artist.value_or(""); | ||||
|         case 5: | ||||
|             return format_filesize(m_playlist_items[index.row()].extended_info->file_size_in_bytes.value_or(0)); | ||||
|         } | ||||
|     } | ||||
|     if (role == GUI::ModelRole::Sort) | ||||
|         return data(index, GUI::ModelRole::Display); | ||||
|     if (role == static_cast<GUI::ModelRole>(PlaylistModelCustomRole::FilePath)) //path
 | ||||
|         return m_playlist_items[index.row()].path; | ||||
| 
 | ||||
|     return {}; | ||||
| } | ||||
| 
 | ||||
| String PlaylistModel::format_filesize(u64 size_in_bytes) | ||||
| { | ||||
|     if (size_in_bytes > GiB) | ||||
|         return String::formatted("{:.2f} GiB", (double)size_in_bytes / GiB); | ||||
|     else if (size_in_bytes > MiB) | ||||
|         return String::formatted("{:.2f} MiB", (double)size_in_bytes / MiB); | ||||
|     else if (size_in_bytes > KiB) | ||||
|         return String::formatted("{:.2f} KiB", (double)size_in_bytes / KiB); | ||||
|     else | ||||
|         return String::formatted("{} B", size_in_bytes); | ||||
| } | ||||
| 
 | ||||
| String PlaylistModel::format_duration(u32 duration_in_seconds) | ||||
| { | ||||
|     return String::formatted("{:02}:{:02}:{:02}", duration_in_seconds / 3600, duration_in_seconds / 60, duration_in_seconds % 60); | ||||
| } | ||||
| 
 | ||||
| String PlaylistModel::column_name(int column) const | ||||
| { | ||||
|     switch (column) { | ||||
|     case 0: | ||||
|         return "Title"; | ||||
|     case 1: | ||||
|         return "Duration"; | ||||
|     case 2: | ||||
|         return "Group"; | ||||
|     case 3: | ||||
|         return "Album"; | ||||
|     case 4: | ||||
|         return "Artist"; | ||||
|     case 5: | ||||
|         return "Filesize"; | ||||
|     } | ||||
|     VERIFY_NOT_REACHED(); | ||||
| } | ||||
| 
 | ||||
| void PlaylistModel::update() | ||||
| { | ||||
| } | ||||
| 
 | ||||
| void PlaylistTableView::doubleclick_event(GUI::MouseEvent& event) | ||||
| { | ||||
|     AbstractView::doubleclick_event(event); | ||||
|     if (event.button() == GUI::Left) { | ||||
|         if (on_doubleclick) | ||||
|             on_doubleclick(event.position()); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										79
									
								
								Userland/Applications/SoundPlayer/PlaylistWidget.h
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								Userland/Applications/SoundPlayer/PlaylistWidget.h
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,79 @@ | |||
| /*
 | ||||
|  * Copyright (c) 2021, Cesar Torres <shortanemoia@protonmail.com> | ||||
|  * All rights reserved. | ||||
|  * | ||||
|  * Redistribution and use in source and binary forms, with or without | ||||
|  * modification, are permitted provided that the following conditions are met: | ||||
|  * | ||||
|  * 1. Redistributions of source code must retain the above copyright notice, this | ||||
|  *    list of conditions and the following disclaimer. | ||||
|  * | ||||
|  * 2. Redistributions in binary form must reproduce the above copyright notice, | ||||
|  *    this list of conditions and the following disclaimer in the documentation | ||||
|  *    and/or other materials provided with the distribution. | ||||
|  * | ||||
|  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" | ||||
|  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE | ||||
|  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE | ||||
|  * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE | ||||
|  * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL | ||||
|  * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR | ||||
|  * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER | ||||
|  * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, | ||||
|  * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | ||||
|  * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | ||||
|  */ | ||||
| 
 | ||||
| #pragma once | ||||
| 
 | ||||
| #include "M3UParser.h" | ||||
| #include <LibGUI/Model.h> | ||||
| #include <LibGUI/TableView.h> | ||||
| #include <LibGUI/Variant.h> | ||||
| #include <LibGUI/Widget.h> | ||||
| 
 | ||||
| enum class PlaylistModelCustomRole { | ||||
|     _DONOTUSE = (int)GUI::ModelRole::Custom, | ||||
|     FilePath | ||||
| }; | ||||
| 
 | ||||
| class PlaylistModel : public GUI::Model { | ||||
| public: | ||||
|     ~PlaylistModel() override = default; | ||||
| 
 | ||||
|     int row_count(const GUI::ModelIndex&) const override { return m_playlist_items.size(); } | ||||
|     int column_count(const GUI::ModelIndex&) const override { return 6; } | ||||
|     GUI::Variant data(const GUI::ModelIndex&, GUI::ModelRole) const override; | ||||
|     void update() override; | ||||
|     String column_name(int column) const override; | ||||
|     Vector<M3UEntry>& items() { return m_playlist_items; } | ||||
| 
 | ||||
| private: | ||||
|     Vector<M3UEntry> m_playlist_items; | ||||
| 
 | ||||
|     static String format_filesize(u64 size_in_bytes); | ||||
|     static String format_duration(u32 duration_in_seconds); | ||||
| }; | ||||
| 
 | ||||
| class PlaylistTableView : public GUI::TableView { | ||||
|     C_OBJECT(PlaylistTableView) | ||||
| public: | ||||
|     void doubleclick_event(GUI::MouseEvent& event) override; | ||||
| 
 | ||||
|     Function<void(const Gfx::Point<int>&)> on_doubleclick; | ||||
| }; | ||||
| 
 | ||||
| class PlaylistWidget : public GUI::Widget { | ||||
|     C_OBJECT(PlaylistWidget) | ||||
| public: | ||||
|     PlaylistWidget(); | ||||
|     void set_data_model(RefPtr<PlaylistModel> model) | ||||
|     { | ||||
|         m_table_view->set_model(model); | ||||
|         m_table_view->update(); | ||||
|     } | ||||
| 
 | ||||
| protected: | ||||
| private: | ||||
|     RefPtr<PlaylistTableView> m_table_view; | ||||
| }; | ||||
|  | @ -26,13 +26,14 @@ | |||
| 
 | ||||
| #include "SoundPlayerWidgetAdvancedView.h" | ||||
| #include "BarsVisualizationWidget.h" | ||||
| #include "Common.h" | ||||
| #include "M3UParser.h" | ||||
| #include "PlaybackManager.h" | ||||
| #include "SoundPlayerWidget.h" | ||||
| #include <AK/LexicalPath.h> | ||||
| #include <AK/SIMD.h> | ||||
| #include <LibGUI/Action.h> | ||||
| #include <LibGUI/BoxLayout.h> | ||||
| #include <LibGUI/Button.h> | ||||
| #include <LibGUI/DragOperation.h> | ||||
| #include <LibGUI/Label.h> | ||||
| #include <LibGUI/MessageBox.h> | ||||
| #include <LibGUI/Slider.h> | ||||
|  | @ -46,11 +47,16 @@ SoundPlayerWidgetAdvancedView::SoundPlayerWidgetAdvancedView(GUI::Window& window | |||
|     , m_window(window) | ||||
| { | ||||
|     window.resize(455, 350); | ||||
|     window.set_minimum_size(440, 130); | ||||
|     window.set_minimum_size(600, 130); | ||||
|     window.set_resizable(true); | ||||
| 
 | ||||
|     set_fill_with_background_color(true); | ||||
| 
 | ||||
|     set_layout<GUI::VerticalBoxLayout>(); | ||||
|     m_splitter = add<GUI::HorizontalSplitter>(); | ||||
|     m_player_view = m_splitter->add<GUI::Widget>(); | ||||
|     m_playlist_model = adopt(*new PlaylistModel()); | ||||
| 
 | ||||
|     m_player_view->set_layout<GUI::VerticalBoxLayout>(); | ||||
| 
 | ||||
|     m_play_icon = Gfx::Bitmap::load_from_file("/res/icons/16x16/play.png"); | ||||
|     m_pause_icon = Gfx::Bitmap::load_from_file("/res/icons/16x16/pause.png"); | ||||
|  | @ -58,9 +64,9 @@ SoundPlayerWidgetAdvancedView::SoundPlayerWidgetAdvancedView(GUI::Window& window | |||
|     m_back_icon = Gfx::Bitmap::load_from_file("/res/icons/16x16/go-back.png"); | ||||
|     m_next_icon = Gfx::Bitmap::load_from_file("/res/icons/16x16/go-forward.png"); | ||||
| 
 | ||||
|     m_visualization = add<BarsVisualizationWidget>(); | ||||
|     m_visualization = m_player_view->add<BarsVisualizationWidget>(); | ||||
| 
 | ||||
|     m_playback_progress_slider = add<Slider>(Orientation::Horizontal); | ||||
|     m_playback_progress_slider = m_player_view->add<AutoSlider>(Orientation::Horizontal); | ||||
|     m_playback_progress_slider->set_fixed_height(20); | ||||
|     m_playback_progress_slider->set_min(0); | ||||
|     m_playback_progress_slider->set_max(this->manager().total_length() * 44100); //this value should be set when we load a new file
 | ||||
|  | @ -68,8 +74,7 @@ SoundPlayerWidgetAdvancedView::SoundPlayerWidgetAdvancedView(GUI::Window& window | |||
|         this->manager().seek(value); | ||||
|     }; | ||||
| 
 | ||||
|     auto& toolbar_container = add<GUI::ToolBarContainer>(); | ||||
| 
 | ||||
|     auto& toolbar_container = m_player_view->add<GUI::ToolBarContainer>(); | ||||
|     auto& menubar = toolbar_container.add<GUI::ToolBar>(); | ||||
| 
 | ||||
|     m_play_button = menubar.add<GUI::Button>(); | ||||
|  | @ -99,16 +104,35 @@ SoundPlayerWidgetAdvancedView::SoundPlayerWidgetAdvancedView(GUI::Window& window | |||
| 
 | ||||
|     // filler_label
 | ||||
|     menubar.add<GUI::Label>(); | ||||
| 
 | ||||
|     m_back_button = menubar.add<GUI::Button>(); | ||||
|     m_back_button->set_fixed_width(50); | ||||
|     m_back_button->set_icon(*m_back_icon); | ||||
|     m_back_button->set_enabled(has_loaded_file()); | ||||
|     m_back_button->on_click = [&](unsigned) { | ||||
|         if (!m_playlist_model.is_null()) { | ||||
|             auto it = m_playlist_model->items().find_if([&](const M3UEntry& e) { return e.path == loaded_filename(); }); | ||||
|             if (it.index() == 0) { | ||||
|                 open_file(m_playlist_model->items().at(m_playlist_model->items().size() - 1).path); | ||||
|             } else | ||||
|                 open_file((it - 1)->path); | ||||
|             play(); | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     m_next_button = menubar.add<GUI::Button>(); | ||||
|     m_next_button->set_fixed_width(50); | ||||
|     m_next_button->set_icon(*m_next_icon); | ||||
|     m_next_button->set_enabled(has_loaded_file()); | ||||
|     m_next_button->on_click = [&](unsigned) { | ||||
|         if (!m_playlist_model.is_null()) { | ||||
|             auto it = m_playlist_model->items().find_if([&](const M3UEntry& e) { return e.path == loaded_filename(); }); | ||||
|             if (it.index() + 1 == m_playlist_model->items().size()) { | ||||
|                 open_file(m_playlist_model->items().at(0).path); | ||||
|             } else | ||||
|                 open_file((it + 1)->path); | ||||
|             play(); | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     m_volume_label = &menubar.add<GUI::Label>(); | ||||
|     m_volume_label->set_fixed_width(30); | ||||
|  | @ -137,30 +161,46 @@ SoundPlayerWidgetAdvancedView::SoundPlayerWidgetAdvancedView(GUI::Window& window | |||
|         m_playback_progress_slider->set_value(samples_played); | ||||
| 
 | ||||
|         dynamic_cast<Visualization*>(m_visualization.ptr())->set_buffer(this->manager().current_buffer()); | ||||
|         dynamic_cast<Visualization*>(m_visualization.ptr())->set_samplerate(loaded_file_samplerate()); | ||||
|     }; | ||||
| 
 | ||||
|     this->manager().on_load_sample_buffer = [&](Audio::Buffer& buffer) { | ||||
|         if (volume() == 1.) | ||||
|     manager().on_load_sample_buffer = [&](Audio::Buffer&) { | ||||
|         //TODO: Implement an equalizer
 | ||||
|         return; | ||||
|     }; | ||||
| 
 | ||||
|     manager().on_finished_playing = [&] { | ||||
|         m_play_button->set_icon(*m_play_icon); | ||||
|         if (looping()) { | ||||
|             open_file(loaded_filename()); | ||||
|             return; | ||||
|         auto sample_count = buffer.sample_count(); | ||||
|         if (sample_count % 4 == 0) { | ||||
|             const int total_iter = sample_count / (sizeof(AK::SIMD::f64x4) / sizeof(double) / 2); | ||||
|             AK::SIMD::f64x4* sample_ptr = const_cast<AK::SIMD::f64x4*>(reinterpret_cast<const AK::SIMD::f64x4*>((buffer.data()))); | ||||
|             for (int i = 0; i < total_iter; ++i) { | ||||
|                 sample_ptr[i] = sample_ptr[i] * volume(); | ||||
|             } | ||||
|         } else { | ||||
|             const int total_iter = sample_count / (sizeof(AK::SIMD::f64x2) / sizeof(double) / 2); | ||||
|             AK::SIMD::f64x2* sample_ptr = const_cast<AK::SIMD::f64x2*>(reinterpret_cast<const AK::SIMD::f64x2*>((buffer.data()))); | ||||
|             for (int i = 0; i < total_iter; ++i) { | ||||
|                 sample_ptr[i] = sample_ptr[i] * volume(); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         if (!m_playlist_model.is_null() && !m_playlist_model->items().is_empty()) { | ||||
|             auto it = m_playlist_model->items().find_if([&](const M3UEntry& e) { return e.path == loaded_filename(); }); | ||||
|             if (it.index() + 1 == m_playlist_model->items().size()) { | ||||
|                 if (looping_playlist()) { | ||||
|                     open_file(m_playlist_model->items().at(0).path); | ||||
|                     return; | ||||
|                 } | ||||
|             } else | ||||
|                 open_file((it + 1)->path); | ||||
|         } | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
| void SoundPlayerWidgetAdvancedView::open_file(StringView path) | ||||
| { | ||||
|     if (!Core::File::exists(path)) { | ||||
|         GUI::MessageBox::show(window(), String::formatted("File \"{}\" does not exist", path), "Error opening file", GUI::MessageBox::Type::Error); | ||||
|         return; | ||||
|     } | ||||
| 
 | ||||
|     if (path.ends_with(".m3u", AK::CaseSensitivity::CaseInsensitive) || path.ends_with(".m3u8", AK::CaseSensitivity::CaseInsensitive)) { | ||||
|         read_playlist(path); | ||||
|         return; | ||||
|     } | ||||
| 
 | ||||
|     NonnullRefPtr<Audio::Loader> loader = Audio::Loader::create(path); | ||||
|     if (loader->has_error() || !loader->sample_rate()) { | ||||
|         const String error_string = loader->error_string(); | ||||
|  | @ -208,3 +248,76 @@ void SoundPlayerWidgetAdvancedView::play() | |||
|     set_paused(false); | ||||
|     set_stopped(false); | ||||
| } | ||||
| 
 | ||||
| void SoundPlayerWidgetAdvancedView::read_playlist(StringView path) | ||||
| { | ||||
|     auto parser = M3UParser::from_file(path); | ||||
|     auto items = parser->parse(true); | ||||
|     VERIFY(items->size() > 0); | ||||
|     try_fill_missing_info(*items, path); | ||||
|     for (auto& item : *items) | ||||
|         m_playlist_model->items().append(item); | ||||
|     set_playlist_visible(true); | ||||
|     m_playlist_model->update(); | ||||
| 
 | ||||
|     open_file(items->at(0).path); | ||||
| 
 | ||||
|     if (items->size() > 1) { | ||||
|         m_back_button->set_enabled(true); | ||||
|         m_next_button->set_enabled(true); | ||||
|     } else { | ||||
|         m_back_button->set_enabled(false); | ||||
|         m_next_button->set_enabled(false); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| void SoundPlayerWidgetAdvancedView::set_playlist_visible(bool visible) | ||||
| { | ||||
|     if (visible) { | ||||
|         m_playlist_widget = m_player_view->parent_widget()->add<PlaylistWidget>(); | ||||
|         m_playlist_widget->set_data_model(m_playlist_model); | ||||
|         m_playlist_widget->set_fixed_width(150); | ||||
|     } else { | ||||
|         m_playlist_widget->remove_from_parent(); | ||||
|         m_player_view->set_max_width(window()->width()); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| void SoundPlayerWidgetAdvancedView::try_fill_missing_info(Vector<M3UEntry>& entries, StringView playlist_p) | ||||
| { | ||||
|     LexicalPath playlist_path(playlist_p); | ||||
|     Vector<M3UEntry*> to_delete; | ||||
|     for (auto& entry : entries) { | ||||
|         LexicalPath entry_path(entry.path); | ||||
|         if (!entry_path.is_absolute()) { | ||||
|             entry.path = String::formatted("{}/{}", playlist_path.dirname(), entry_path.basename()); | ||||
|         } | ||||
| 
 | ||||
|         if (!Core::File::exists(entry.path)) { | ||||
|             GUI::MessageBox::show(window(), String::formatted("The file \"{}\" present in the playlist does not exist or was not found. This file will be ignored.", entry_path.basename()), "Error reading playlist", GUI::MessageBox::Type::Warning); | ||||
|             to_delete.append(&entry); | ||||
|             continue; | ||||
|         } | ||||
| 
 | ||||
|         if (!entry.extended_info->track_display_title.has_value()) | ||||
|             entry.extended_info->track_display_title = LexicalPath(entry.path).title(); | ||||
|         if (!entry.extended_info->track_length_in_seconds.has_value()) { | ||||
|             if (entry_path.has_extension("wav")) { | ||||
|                 auto wav_reader = Audio::Loader::create(entry.path); | ||||
|                 entry.extended_info->track_length_in_seconds = wav_reader->total_samples() / wav_reader->sample_rate(); | ||||
|             } | ||||
|             //TODO: Implement embedded metadata extractor for other audio formats
 | ||||
|         } | ||||
|         //TODO: Implement a metadata parser for the uncomfortably numerous popular embedded metadata formats
 | ||||
| 
 | ||||
|         if (!entry.extended_info->file_size_in_bytes.has_value()) { | ||||
|             FILE* f = fopen(entry.path.characters(), "r"); | ||||
|             VERIFY(f != nullptr); | ||||
|             fseek(f, 0, SEEK_END); | ||||
|             entry.extended_info->file_size_in_bytes = ftell(f); | ||||
|             fclose(f); | ||||
|         } | ||||
|     } | ||||
|     for (M3UEntry* entry : to_delete) | ||||
|         entries.remove_first_matching([&](M3UEntry& e) { return &e == entry; }); | ||||
| } | ||||
|  |  | |||
|  | @ -43,6 +43,7 @@ public: | |||
|     ~SoundPlayerWidgetAdvancedView() override; | ||||
| 
 | ||||
|     void open_file(StringView path) override; | ||||
|     void read_playlist(StringView path); | ||||
|     void play() override; | ||||
| 
 | ||||
|     template<typename T> | ||||
|  | @ -61,6 +62,9 @@ private: | |||
|     void drop_event(GUI::DropEvent& event) override; | ||||
|     GUI::Window& m_window; | ||||
| 
 | ||||
|     RefPtr<GUI::HorizontalSplitter> m_splitter; | ||||
|     RefPtr<GUI::Widget> m_player_view; | ||||
|     RefPtr<PlaylistWidget> m_playlist_widget; | ||||
|     RefPtr<GUI::Widget> m_visualization; | ||||
| 
 | ||||
|     RefPtr<Gfx::Bitmap> m_play_icon; | ||||
|  |  | |||
|  | @ -93,36 +93,24 @@ int main(int argc, char** argv) | |||
|         } | ||||
|     })); | ||||
| 
 | ||||
|     RefPtr<GUI::Action> hide_scope; | ||||
| 
 | ||||
|     auto advanced_view_check = GUI::Action::create_checkable("Advanced view", { Mod_Ctrl, Key_A }, [&](auto& action) { | ||||
|         PlayerState state = player->get_player_state(); | ||||
|         window->close(); | ||||
|         if (action.is_checked()) { | ||||
|             player = &window->set_main_widget<SoundPlayerWidgetAdvancedView>(window, state); | ||||
|             hide_scope->set_checkable(false); | ||||
|         } else { | ||||
|             player = &window->set_main_widget<SoundPlayerWidget>(window, state); | ||||
|             hide_scope->set_checkable(true); | ||||
|         } | ||||
|         window->show(); | ||||
|     }); | ||||
|     app_menu.add_action(advanced_view_check); | ||||
| 
 | ||||
|     hide_scope = GUI::Action::create_checkable("Hide visualization (legacy view)", { Mod_Ctrl, Key_H }, [&](auto& action) { | ||||
|         if (!advanced_view_check->is_checked()) | ||||
|             static_cast<SoundPlayerWidget*>(player)->hide_scope(action.is_checked()); | ||||
|     }); | ||||
| 
 | ||||
|     auto linear_volume_slider = GUI::Action::create_checkable("Nonlinear volume slider", [&](auto& action) { | ||||
|         if (advanced_view_check->is_checked()) | ||||
|             static_cast<SoundPlayerWidgetAdvancedView*>(player)->set_nonlinear_volume_slider(action.is_checked()); | ||||
|         static_cast<SoundPlayerWidgetAdvancedView*>(player)->set_nonlinear_volume_slider(action.is_checked()); | ||||
|     }); | ||||
|     app_menu.add_action(linear_volume_slider); | ||||
| 
 | ||||
|     auto ptr_copy = hide_scope; | ||||
|     auto playlist_toggle = GUI::Action::create_checkable("Show playlist", [&](auto& action) { | ||||
|         static_cast<SoundPlayerWidgetAdvancedView*>(player)->set_playlist_visible(action.is_checked()); | ||||
|     }); | ||||
|     playlist_menu.add_action(playlist_toggle); | ||||
|     if (path.ends_with(".m3u") || path.ends_with(".m3u8")) | ||||
|         playlist_toggle->set_checked(true); | ||||
|     playlist_menu.add_separator(); | ||||
| 
 | ||||
|     auto playlist_loop_toggle = GUI::Action::create_checkable("Loop playlist", [&](auto& action) { | ||||
|         static_cast<SoundPlayerWidgetAdvancedView*>(player)->set_looping_playlist(action.is_checked()); | ||||
|     }); | ||||
|     playlist_menu.add_action(playlist_loop_toggle); | ||||
| 
 | ||||
|     app_menu.add_action(ptr_copy.release_nonnull()); | ||||
|     app_menu.add_separator(); | ||||
|     app_menu.add_action(GUI::CommonActions::make_quit_action([&](auto&) { | ||||
|         app->quit(); | ||||
|  | @ -174,6 +162,7 @@ int main(int argc, char** argv) | |||
|             action.set_checked(true); | ||||
|             return; | ||||
|         } | ||||
|         checked_vis = &action; | ||||
|         static_cast<SoundPlayerWidgetAdvancedView*>(player)->set_visualization<NoVisualizationWidget>(); | ||||
|     }); | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Cesar Torres
						Cesar Torres