diff --git a/Base/home/anon/.config/LaunchServer.ini b/Base/home/anon/.config/LaunchServer.ini index ddafda7c8d..45636346c7 100644 --- a/Base/home/anon/.config/LaunchServer.ini +++ b/Base/home/anon/.config/LaunchServer.ini @@ -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 diff --git a/Userland/Applications/SoundPlayer/CMakeLists.txt b/Userland/Applications/SoundPlayer/CMakeLists.txt index 70c3d79965..2596ea7fcc 100644 --- a/Userland/Applications/SoundPlayer/CMakeLists.txt +++ b/Userland/Applications/SoundPlayer/CMakeLists.txt @@ -7,6 +7,8 @@ set(SOURCES BarsVisualizationWidget.cpp AudioAlgorithms.cpp NoVisualizationWidget.cpp + M3UParser.cpp + PlaylistWidget.cpp ) serenity_app(SoundPlayer ICON app-sound-player) diff --git a/Userland/Applications/SoundPlayer/M3UParser.cpp b/Userland/Applications/SoundPlayer/M3UParser.cpp new file mode 100644 index 0000000000..fd5540f28d --- /dev/null +++ b/Userland/Applications/SoundPlayer/M3UParser.cpp @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2021, Cesar Torres + * 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 +#include +#include +#include +#include +#include + +M3UParser::M3UParser() +{ +} + +NonnullOwnPtr M3UParser::from_file(const String path) +{ + + auto parser = make(); + 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 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::from_memory(const String& m3u_contents, bool utf8) +{ + auto parser = make(); + 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> M3UParser::parse(bool include_extended_info) +{ + auto vec = make>(); + + 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; +} diff --git a/Userland/Applications/SoundPlayer/M3UParser.h b/Userland/Applications/SoundPlayer/M3UParser.h new file mode 100644 index 0000000000..a32f9564de --- /dev/null +++ b/Userland/Applications/SoundPlayer/M3UParser.h @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2021, Cesar Torres + * 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 +#include +#include +#include +#include + +// Extended M3U fields (de facto standard) +struct M3UExtendedInfo { + Optional track_length_in_seconds; + Optional track_display_title; + Optional group_name; + Optional album_title; + Optional album_artist; + Optional album_genre; + Optional file_size_in_bytes; + Optional embedded_mp3; + Optional cover_path; +}; + +struct M3UEntry { + String path; + Optional extended_info; +}; + +class M3UParser { +public: + static NonnullOwnPtr from_file(String path); + static NonnullOwnPtr from_memory(const String& m3u_contents, bool utf8); + + NonnullOwnPtr> parse(bool include_extended_info); + + Optional& 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 m_parsed_playlist_title; +}; diff --git a/Userland/Applications/SoundPlayer/Player.h b/Userland/Applications/SoundPlayer/Player.h index 598c8b0ca4..00d5b65a3f 100644 --- a/Userland/Applications/SoundPlayer/Player.h +++ b/Userland/Applications/SoundPlayer/Player.h @@ -27,6 +27,7 @@ #pragma once #include "PlaybackManager.h" +#include "PlaylistWidget.h" #include "VisualizationBase.h" #include @@ -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 m_playlist_model; }; diff --git a/Userland/Applications/SoundPlayer/PlaylistWidget.cpp b/Userland/Applications/SoundPlayer/PlaylistWidget.cpp new file mode 100644 index 0000000000..95f9cf132a --- /dev/null +++ b/Userland/Applications/SoundPlayer/PlaylistWidget.cpp @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2021, Cesar Torres + * 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 +#include +#include +#include +#include + +PlaylistWidget::PlaylistWidget() +{ + set_layout(); + m_table_view = add(); + 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& point) { + auto player = dynamic_cast(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(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(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()); + } +} diff --git a/Userland/Applications/SoundPlayer/PlaylistWidget.h b/Userland/Applications/SoundPlayer/PlaylistWidget.h new file mode 100644 index 0000000000..b561e6ba43 --- /dev/null +++ b/Userland/Applications/SoundPlayer/PlaylistWidget.h @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2021, Cesar Torres + * 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 +#include +#include +#include + +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& items() { return m_playlist_items; } + +private: + Vector 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&)> on_doubleclick; +}; + +class PlaylistWidget : public GUI::Widget { + C_OBJECT(PlaylistWidget) +public: + PlaylistWidget(); + void set_data_model(RefPtr model) + { + m_table_view->set_model(model); + m_table_view->update(); + } + +protected: +private: + RefPtr m_table_view; +}; diff --git a/Userland/Applications/SoundPlayer/SoundPlayerWidgetAdvancedView.cpp b/Userland/Applications/SoundPlayer/SoundPlayerWidgetAdvancedView.cpp index 4d11fb4c0c..ee9accbc26 100644 --- a/Userland/Applications/SoundPlayer/SoundPlayerWidgetAdvancedView.cpp +++ b/Userland/Applications/SoundPlayer/SoundPlayerWidgetAdvancedView.cpp @@ -26,13 +26,14 @@ #include "SoundPlayerWidgetAdvancedView.h" #include "BarsVisualizationWidget.h" +#include "Common.h" +#include "M3UParser.h" #include "PlaybackManager.h" -#include "SoundPlayerWidget.h" +#include #include #include #include #include -#include #include #include #include @@ -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(); + m_splitter = add(); + m_player_view = m_splitter->add(); + m_playlist_model = adopt(*new PlaylistModel()); + + m_player_view->set_layout(); 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(); + m_visualization = m_player_view->add(); - m_playback_progress_slider = add(Orientation::Horizontal); + m_playback_progress_slider = m_player_view->add(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(); - + auto& toolbar_container = m_player_view->add(); auto& menubar = toolbar_container.add(); m_play_button = menubar.add(); @@ -99,16 +104,35 @@ SoundPlayerWidgetAdvancedView::SoundPlayerWidgetAdvancedView(GUI::Window& window // filler_label menubar.add(); - m_back_button = menubar.add(); 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(); 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(); 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(m_visualization.ptr())->set_buffer(this->manager().current_buffer()); + dynamic_cast(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(reinterpret_cast((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(reinterpret_cast((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 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(); + 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& entries, StringView playlist_p) +{ + LexicalPath playlist_path(playlist_p); + Vector 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; }); +} diff --git a/Userland/Applications/SoundPlayer/SoundPlayerWidgetAdvancedView.h b/Userland/Applications/SoundPlayer/SoundPlayerWidgetAdvancedView.h index 11755ad9cb..128a352650 100644 --- a/Userland/Applications/SoundPlayer/SoundPlayerWidgetAdvancedView.h +++ b/Userland/Applications/SoundPlayer/SoundPlayerWidgetAdvancedView.h @@ -43,6 +43,7 @@ public: ~SoundPlayerWidgetAdvancedView() override; void open_file(StringView path) override; + void read_playlist(StringView path); void play() override; template @@ -61,6 +62,9 @@ private: void drop_event(GUI::DropEvent& event) override; GUI::Window& m_window; + RefPtr m_splitter; + RefPtr m_player_view; + RefPtr m_playlist_widget; RefPtr m_visualization; RefPtr m_play_icon; diff --git a/Userland/Applications/SoundPlayer/main.cpp b/Userland/Applications/SoundPlayer/main.cpp index 72d2b313b4..3242a37f1c 100644 --- a/Userland/Applications/SoundPlayer/main.cpp +++ b/Userland/Applications/SoundPlayer/main.cpp @@ -93,36 +93,24 @@ int main(int argc, char** argv) } })); - RefPtr 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(window, state); - hide_scope->set_checkable(false); - } else { - player = &window->set_main_widget(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(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(player)->set_nonlinear_volume_slider(action.is_checked()); + static_cast(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(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(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(player)->set_visualization(); });