diff --git a/io.elementary.music.yml b/io.elementary.music.yml index ddef1510f..a13c2d8e6 100644 --- a/io.elementary.music.yml +++ b/io.elementary.music.yml @@ -9,7 +9,8 @@ finish-args: - '--socket=wayland' - '--socket=pulseaudio' - '--device=dri' - - '--filesystem=xdg-music:ro' + - '--filesystem=xdg-music' + - '--add-policy=Tracker3.dbus:org.freedesktop.Tracker3.Miner.Files=tracker:Audio' modules: - name: music diff --git a/meson.build b/meson.build index e7fe144e7..85ac9e570 100644 --- a/meson.build +++ b/meson.build @@ -25,6 +25,7 @@ gstreamer_dep = dependency('gstreamer-1.0') gstreamer_pbutils_dep = dependency('gstreamer-pbutils-1.0') gstreamer_tag_dep = dependency('gstreamer-tag-1.0') gtk_dep = dependency('gtk4') +tracker_dep = dependency('tracker-sparql-3.0') dependencies = [ adw_dep, @@ -32,7 +33,8 @@ dependencies = [ gstreamer_dep, gstreamer_pbutils_dep, gstreamer_tag_dep, - gtk_dep + gtk_dep, + tracker_dep ] gnome.post_install(glib_compile_schemas: true) diff --git a/src/Application.vala b/src/Application.vala index 45f557a14..7f8aa77a4 100644 --- a/src/Application.vala +++ b/src/Application.vala @@ -10,6 +10,7 @@ public class Music.Application : Gtk.Application { public const string ACTION_PREVIOUS = "action-previous"; public const string ACTION_SHUFFLE = "action-shuffle"; public const string ACTION_FIND = "action-find"; + public const string ACTION_OPEN = "action-open"; public const string ACTION_QUIT = "action-quit"; private const ActionEntry[] ACTION_ENTRIES = { @@ -18,6 +19,7 @@ public class Music.Application : Gtk.Application { { ACTION_PREVIOUS, action_previous }, { ACTION_SHUFFLE, action_shuffle }, { ACTION_FIND, action_find }, + { ACTION_OPEN, action_open }, { ACTION_QUIT, quit } }; @@ -187,7 +189,61 @@ public class Music.Application : Gtk.Application { } private void action_find () { - ((MainWindow)active_window).start_search (); + // ((MainWindow)active_window).start_search (); + } + + private void action_open () { + var all_files_filter = new Gtk.FileFilter () { + name = _("All files"), + }; + all_files_filter.add_pattern ("*"); + + var music_files_filter = new Gtk.FileFilter () { + name = _("Music files"), + }; + music_files_filter.add_mime_type ("audio/*"); + + var filter_model = new ListStore (typeof (Gtk.FileFilter)); + filter_model.append (all_files_filter); + filter_model.append (music_files_filter); + + var file_dialog = new Gtk.FileDialog () { + accept_label = _("Open"), + default_filter = music_files_filter, + filters = filter_model, + modal = true, + title = _("Open audio files") + }; + + file_dialog.open_multiple.begin (active_window, null, (obj, res) => { + try { + var files = file_dialog.open_multiple.end (res); + + File[] file_array = {}; + for (int i = 0; i < files.get_n_items (); i++) { + file_array += (File)(files.get_item (i)); + } + + var files_to_play = Application.loop_through_files (file_array); + PlaybackManager.get_default ().queue_files (files_to_play); + } catch (Error e) { + if (e.matches (Gtk.DialogError.quark (), Gtk.DialogError.DISMISSED)) { + return; + } + + var dialog = new Granite.MessageDialog ( + "Couldn't add audio files", + e.message, + new ThemedIcon ("document-open") + ) { + badge_icon = new ThemedIcon ("dialog-error"), + modal = true, + transient_for = active_window + }; + dialog.present (); + dialog.response.connect (dialog.destroy); + } + }); } private void on_bus_acquired (DBusConnection connection, string name) { diff --git a/src/AudioObject.vala b/src/AudioObject.vala index b80934a9d..fd30ba724 100644 --- a/src/AudioObject.vala +++ b/src/AudioObject.vala @@ -4,13 +4,81 @@ */ public class Music.AudioObject : Object { - public string uri { get; construct; } + public string uri { get; set; } public Gdk.Texture texture { get; set; } public string artist { get; set; } public string title { get; set; } public int64 duration { get; set; default = 0; } - public AudioObject (string uri) { - Object (uri: uri); + public AudioObject.from_file (File file) { + uri = file.get_uri (); + + string? basename = file.get_basename (); + + if (basename != null) { + title = basename; + } else { + title = uri; + } + } + + public void update_metadata () { + try { + new Thread.try (null, () => { + try { + var discoverer = new Gst.PbUtils.Discoverer ((Gst.ClockTime) (5 * Gst.SECOND)); + + var info = discoverer.discover_uri (uri); + + if (info == null) { + warning ("Discovery failed."); + return null; + } + + unowned Gst.TagList? tag_list = info.get_tags (); + + duration = (int64) info.get_duration (); + + string _title; + tag_list.get_string (Gst.Tags.TITLE, out _title); + if (_title != null) { + title = _title; + } + + string _artist; + tag_list.get_string (Gst.Tags.ARTIST, out _artist); + if (_artist != null) { + artist = _artist; + } else if (_title != null) { // Don't set artist for files without tags + artist = _("Unknown"); + } + + var sample = PlaybackManager.get_cover_sample (tag_list); + if (sample != null) { + var buffer = sample.get_buffer (); + + Gst.MapInfo? map_info = null; + if (buffer != null && buffer.map (out map_info, Gst.MapFlags.READ) && map_info != null) { + var bytes = new Bytes (map_info.data); + try { + texture = Gdk.Texture.from_bytes (bytes); + } catch (Error e) { + warning ("Error processing image data: %s", e.message); + } + + buffer.unmap (map_info); + } else { + warning ("Could not map memory buffer"); + } + } + } catch (Error e) { + warning ("Failed to create texture: %s", e.message); + } + + return null; + }); + } catch (Error e) { + warning ("Failed to create thread: %s", e.message); + } } } diff --git a/src/MainWindow.vala b/src/MainWindow.vala index 4fcac567e..164641f31 100644 --- a/src/MainWindow.vala +++ b/src/MainWindow.vala @@ -4,107 +4,24 @@ */ public class Music.MainWindow : Gtk.ApplicationWindow { - private Gtk.Button repeat_button; - private Gtk.Button shuffle_button; private Settings settings; - private Gtk.SearchEntry search_entry; - private Gtk.Revealer search_revealer; construct { - var playback_manager = PlaybackManager.get_default (); + var start_window_controls = new Gtk.WindowControls (START); - var start_window_controls = new Gtk.WindowControls (Gtk.PackType.START); - - shuffle_button = new Gtk.Button.from_icon_name ("media-playlist-shuffle-symbolic") { - action_name = Application.ACTION_PREFIX + Application.ACTION_SHUFFLE, - tooltip_text = _("Shuffle") + var stack_switcher = new Gtk.StackSwitcher () { + hexpand = false }; + ((Gtk.BoxLayout)stack_switcher.get_layout_manager ()).homogeneous = true; - repeat_button = new Gtk.Button (); - - search_entry = new Gtk.SearchEntry () { - placeholder_text = _("Search titles in playlist") - }; - - search_revealer = new Gtk.Revealer () { - child = search_entry - }; - - playback_manager.bind_property ( - "has-items", search_revealer, "reveal-child", DEFAULT | SYNC_CREATE - ); - var queue_header = new Gtk.HeaderBar () { + var start_header = new Gtk.HeaderBar () { show_title_buttons = false, - title_widget = search_revealer - }; - queue_header.add_css_class (Granite.STYLE_CLASS_DEFAULT_DECORATION); - queue_header.pack_start (start_window_controls); - queue_header.pack_end (shuffle_button); - queue_header.pack_end (repeat_button); - - var queue_placeholder = new Granite.Placeholder (_("Queue is Empty")) { - description = _("Audio files opened from Files will appear here"), - icon = new ThemedIcon ("playlist-queue") + title_widget = stack_switcher }; + start_header.add_css_class (Granite.STYLE_CLASS_FLAT); + start_header.pack_start (start_window_controls); - var queue_listbox = new Gtk.ListBox () { - hexpand = true, - vexpand = true - }; - queue_listbox.bind_model (playback_manager.queue_liststore, create_queue_row); - queue_listbox.set_placeholder (queue_placeholder); - - var scrolled = new Gtk.ScrolledWindow () { - child = queue_listbox - }; - - var drop_target = new Gtk.DropTarget (typeof (Gdk.FileList), Gdk.DragAction.COPY); - - var add_button_label = new Gtk.Label (_("Open Files…")); - - var add_button_box = new Gtk.Box (HORIZONTAL, 0); - add_button_box.append (new Gtk.Image.from_icon_name ("document-open-symbolic")); - add_button_box.append (add_button_label); - - var add_button = new Gtk.Button () { - child = add_button_box, - }; - add_button.add_css_class (Granite.STYLE_CLASS_FLAT); - - add_button_label.mnemonic_widget = add_button; - - var queue_action_bar = new Gtk.ActionBar (); - queue_action_bar.pack_start (add_button); - - var queue = new Adw.ToolbarView () { - bottom_bar_style = RAISED, - content = scrolled - }; - queue.add_controller (drop_target); - queue.add_css_class (Granite.STYLE_CLASS_VIEW); - queue.add_top_bar (queue_header); - queue.add_bottom_bar (queue_action_bar); - - var error_toast = new Granite.Toast (""); - - var queue_overlay = new Gtk.Overlay () { - child = queue - }; - queue_overlay.add_overlay (error_toast); - - var queue_handle = new Gtk.WindowHandle () { - child = queue_overlay - }; - - var end_window_controls = new Gtk.WindowControls (Gtk.PackType.END); - - var end_header = new Gtk.HeaderBar () { - show_title_buttons = false, - title_widget = new Gtk.Label ("") - }; - end_header.add_css_class (Granite.STYLE_CLASS_FLAT); - end_header.add_css_class (Granite.STYLE_CLASS_DEFAULT_DECORATION); - end_header.pack_end (end_window_controls); + var library_view = new LibraryView (); var now_playing_view = new NowPlayingView () { margin_top = 12, @@ -114,17 +31,26 @@ public class Music.MainWindow : Gtk.ApplicationWindow { vexpand = true }; - var now_playing = new Gtk.Box (VERTICAL, 0); - now_playing.append (end_header); - now_playing.append (now_playing_view); - var now_playing_handle = new Gtk.WindowHandle () { - child = now_playing + child = now_playing_view }; + var stack = new Gtk.Stack (); + stack.add_titled (library_view, null, _("Library")); + stack.add_titled (now_playing_handle, null, _("Now Playing")); + + stack_switcher.stack = stack; + + var start_box = new Gtk.Box (VERTICAL, 0); + start_box.add_css_class (Granite.STYLE_CLASS_VIEW); + start_box.append (start_header); + start_box.append (stack); + + var queue_view = new QueueView (); + var paned = new Gtk.Paned (Gtk.Orientation.HORIZONTAL) { - start_child = queue_handle, - end_child = now_playing_handle, + start_child = start_box, + end_child = queue_view, resize_end_child = false, shrink_end_child = false, shrink_start_child = false @@ -140,149 +66,5 @@ public class Music.MainWindow : Gtk.ApplicationWindow { settings = new Settings ("io.elementary.music"); settings.bind ("pane-position", paned, "position", SettingsBindFlags.DEFAULT); - settings.changed["repeat-mode"].connect (update_repeat_button); - - update_repeat_button (); - - drop_target.drop.connect ((target, value, x, y) => { - if (value.type () == typeof (Gdk.FileList)) { - var list = (Gdk.FileList)value; - - File[] file_array = {}; - foreach (unowned var file in list.get_files ()) { - file_array += file; - } - - var files_to_play = Application.loop_through_files (file_array); - PlaybackManager.get_default ().queue_files (files_to_play); - - return true; - } - - return false; - }); - - playback_manager.invalids_found.connect ((count) => { - error_toast.title = ngettext ( - "%d invalid file was not added to the queue", - "%d invalid files were not added to the queue", - count).printf (count); - error_toast.send_notification (); - }); - - add_button.clicked.connect (action_open); - - repeat_button.clicked.connect (() => { - var enum_step = settings.get_enum ("repeat-mode"); - if (enum_step < 2) { - settings.set_enum ("repeat-mode", enum_step + 1); - } else { - settings.set_enum ("repeat-mode", 0); - } - }); - - queue_listbox.row_activated.connect ((row) => { - playback_manager.current_audio = ((TrackRow) row).audio_object; - }); - - search_entry.search_changed.connect (() => { - int pos = playback_manager.find_title (search_entry.text); - if (pos >= 0) { - queue_listbox.select_row (queue_listbox.get_row_at_index (pos)); - var adj = scrolled.vadjustment; - // Search entry is hidden if n_items is zero so no need to check - var ratio = (double)pos / (double)playback_manager.n_items; - adj.@value = adj.upper * ratio; - } - }); - - search_entry.activate.connect (() => { - var selected = queue_listbox.get_selected_row (); - if (selected != null) { - selected.activate (); - } - }); - } - - public void start_search () { - if (search_revealer.child_revealed) { - search_entry.grab_focus (); - } - } - - private void action_open () { - var all_files_filter = new Gtk.FileFilter () { - name = _("All files"), - }; - all_files_filter.add_pattern ("*"); - - var music_files_filter = new Gtk.FileFilter () { - name = _("Music files"), - }; - music_files_filter.add_mime_type ("audio/*"); - - var filter_model = new ListStore (typeof (Gtk.FileFilter)); - filter_model.append (all_files_filter); - filter_model.append (music_files_filter); - - var file_dialog = new Gtk.FileDialog () { - accept_label = _("Open"), - default_filter = music_files_filter, - filters = filter_model, - modal = true, - title = _("Open audio files") - }; - - file_dialog.open_multiple.begin (this, null, (obj, res) => { - try { - var files = file_dialog.open_multiple.end (res); - - File[] file_array = {}; - for (int i = 0; i < files.get_n_items (); i++) { - file_array += (File)(files.get_item (i)); - } - - var files_to_play = Application.loop_through_files (file_array); - PlaybackManager.get_default ().queue_files (files_to_play); - } catch (Error e) { - if (e.matches (Gtk.DialogError.quark (), Gtk.DialogError.DISMISSED)) { - return; - } - - var dialog = new Granite.MessageDialog ( - "Couldn't add audio files", - e.message, - new ThemedIcon ("document-open") - ) { - badge_icon = new ThemedIcon ("dialog-error"), - modal = true, - transient_for = this - }; - dialog.present (); - dialog.response.connect (dialog.destroy); - } - }); - } - - private void update_repeat_button () { - switch (settings.get_string ("repeat-mode")) { - case "disabled": - repeat_button.icon_name = "media-playlist-no-repeat-symbolic"; - repeat_button.tooltip_text = _("Repeat None"); - break; - case "all": - repeat_button.icon_name = "media-playlist-repeat-symbolic"; - repeat_button.tooltip_text = _("Repeat All"); - break; - case "one": - repeat_button.icon_name = "media-playlist-repeat-song-symbolic"; - repeat_button.tooltip_text = _("Repeat One"); - break; - } - } - - private Gtk.Widget create_queue_row (GLib.Object object) { - unowned var audio_object = (AudioObject) object; - return new TrackRow (audio_object); } } diff --git a/src/Services/LibraryManager.vala b/src/Services/LibraryManager.vala new file mode 100644 index 000000000..8c2c9fe36 --- /dev/null +++ b/src/Services/LibraryManager.vala @@ -0,0 +1,217 @@ +public class Music.LibraryManager : Object { + public ListStore songs { get; construct; } + + private Tracker.Sparql.Connection tracker_connection; + private Tracker.Notifier notifier; + private HashTable songs_by_id; + + private static GLib.Once instance; + public static unowned LibraryManager get_instance () { + return instance.once (() => { return new LibraryManager (); }); + } + + construct { + songs = new ListStore (typeof (AudioObject)); + songs_by_id = new HashTable (str_hash, str_equal); + + try { + tracker_connection = Tracker.Sparql.Connection.bus_new ("org.freedesktop.Tracker3.Miner.Files", null, null); + + notifier = tracker_connection.create_notifier (); + if (notifier != null) { + notifier.events.connect (on_tracker_event); + } + } catch (Error e) { + warning (e.message); + } + } + + public async void get_audio_files () { + try { + // There currently is a bug in tracker that from a flatpak large queries will stall indefinitely. + // Therefore we query all ID's and do separate queries for the details of each ID + // This will cost us quite a bit of performance which shouldn't be visible though + // as it only leads to the library filling bit by bit but doesn't block anything + // Tested with Ryzen 5 3600 and about 600 Songs it took half a second to fully load + var tracker_statement_id = tracker_connection.query_statement ( + """ + SELECT tracker:id(?urn) ?sort_prop + WHERE { + GRAPH tracker:Audio { + SELECT ?song AS ?urn ?sort_prop + WHERE { + ?song a nmm:MusicPiece ; + nie:isStoredAs ?sort_prop . + } + } + } + ORDER BY ?sort_prop + """ + ); + + var id_cursor = yield tracker_statement_id.execute_async (null); + + while (yield id_cursor.next_async ()) { + yield query_update_audio_object (id_cursor.get_integer (0), false); + } + + id_cursor.close (); + + // This would be the actual query: + + // var tracker_statement = tracker_connection.query_statement ( + // """ + // SELECT ?url ?title ?artist ?duration + // WHERE { + // GRAPH tracker:Audio { + // SELECT ?url ?title ?artist ?duration + // WHERE { + // ?song a nmm:MusicPiece ; + // nie:isStoredAs ?url . + // OPTIONAL { + // ?song nie:title ?title + // } . + // OPTIONAL { + // ?song nmm:artist [ nmm:artistName ?artist ] ; + // } . + // OPTIONAL { + // ?song nfo:duration ?duration ; + // } . + // } + // } + // } + // """ + // ); + } catch (Error e) { + warning (e.message); + } + } + + private void on_tracker_event (string? service, string? graph, GenericArray events) { + foreach (var event in events) { + var type = event.get_event_type (); + switch (type) { + case DELETE: + var id = event.get_id ().to_string (); + var audio_object = songs_by_id[id]; + + if (audio_object != null) { + songs_by_id.remove (id); + + uint position = Gtk.INVALID_LIST_POSITION; + if (songs.find_with_equal_func (audio_object, equal_func, out position)) { + songs.remove (position); + } + } + break; + + case CREATE: + query_update_audio_object.begin (event.get_id (), false); + break; + + case UPDATE: + query_update_audio_object.begin (event.get_id (), true); + break; + } + } + } + + private async void query_update_audio_object (int64 id, bool update) { + try { + var tracker_statement = tracker_connection.query_statement ( + """ + SELECT ?url ?title ?artist ?duration + WHERE { + GRAPH tracker:Audio { + SELECT ?song ?url ?title ?artist ?duration + WHERE { + ?song a nmm:MusicPiece ; + nie:isStoredAs ?url . + OPTIONAL { + ?song nie:title ?title + } . + OPTIONAL { + ?song nmm:artist [ nmm:artistName ?artist ] ; + } . + OPTIONAL { + ?song nfo:duration ?duration ; + } . + FILTER(tracker:id (?song) = ~id) + } + } + } + """ + ); + + tracker_statement.bind_int ("id", id); + + var cursor = yield tracker_statement.execute_async (null); + + while (cursor.next ()) { + create_audio_object (id, cursor, update); + } + + cursor.close (); + } catch (Error e) { + warning (e.message); + } + } + + private void create_audio_object (int64 _id, Tracker.Sparql.Cursor cursor, bool update = false) { + var id = _id.to_string (); //TODO: Maybe use the int64 directly as key + + AudioObject? audio_object = songs_by_id[id]; + + uint position = Gtk.INVALID_LIST_POSITION; + bool found = false; + + if (audio_object == null) { + audio_object = new AudioObject (); + } else if (!update) { + return; + } else { + found = songs.find_with_equal_func (audio_object, equal_func, out position); + } + + audio_object.uri = cursor.get_string (0); + + // We set the following properties although they are set anyway in + // update_metadata. We do this because update_metadata takes a while + // and we want the ui to already show something + + if (cursor.is_bound (1)) { + audio_object.title = cursor.get_string (1); + } else { + try { + audio_object.title = Filename.display_basename (Filename.from_uri (audio_object.uri)); + } catch (Error e) { + audio_object.title = audio_object.uri; + } + } + + if (cursor.is_bound (2)) { + audio_object.artist = cursor.get_string (2); + } + + if (cursor.is_bound (3)) { + audio_object.duration = cursor.get_integer (3); + } + + audio_object.update_metadata (); + + if (found) { + songs.items_changed (position, 1, 1); + } else { + songs.insert_sorted (audio_object, compare_func); + songs_by_id[id] = audio_object; + } + } + + private static int compare_func (Object a, Object b) { + return ((AudioObject) a).title.collate (((AudioObject) b).title); + } + + private static bool equal_func (Object a, Object b) { + return ((AudioObject) a).uri == ((AudioObject) b).uri; + } +} diff --git a/src/PlaybackManager.vala b/src/Services/PlaybackManager.vala similarity index 70% rename from src/PlaybackManager.vala rename to src/Services/PlaybackManager.vala index 7147863b1..90e09b95e 100644 --- a/src/PlaybackManager.vala +++ b/src/Services/PlaybackManager.vala @@ -4,6 +4,8 @@ */ public class Music.PlaybackManager : Object { + public signal void invalids_found (int count); + public AudioObject? current_audio { get; set; default = null; } public ListStore queue_liststore { get; private set; } public bool has_items { get; private set; } @@ -13,7 +15,6 @@ public class Music.PlaybackManager : Object { } } public int64 playback_position { get; private set; } - public signal void invalids_found (int count); private static GLib.Once instance; public static unowned PlaybackManager get_default () { @@ -21,7 +22,6 @@ public class Music.PlaybackManager : Object { } private dynamic Gst.Element playbin; - private Gst.PbUtils.Discoverer discoverer; private uint progress_timer = 0; private Settings settings; @@ -46,14 +46,6 @@ public class Music.PlaybackManager : Object { bus.add_watch (0, bus_callback); bus.enable_sync_message_emission (); - try { - discoverer = new Gst.PbUtils.Discoverer ((Gst.ClockTime) (5 * Gst.SECOND)); - discoverer.discovered.connect (update_metadata); - discoverer.finished.connect (discoverer.stop); - } catch (Error e) { - critical ("Unable to start Gstreamer Discoverer: %s", e.message); - } - queue_liststore.items_changed.connect (() => { var shuffle_action_action = (SimpleAction) GLib.Application.get_default ().lookup_action (Application.ACTION_SHUFFLE); has_items = queue_liststore.get_n_items () > 0; @@ -91,22 +83,10 @@ public class Music.PlaybackManager : Object { // Files[] must not contain any null entries public void queue_files (File[] files) { - discoverer.start (); int invalids = 0; foreach (unowned var file in files) { if (file.query_exists () && "audio" in ContentType.guess (file.get_uri (), null, null)) { - var audio_object = new AudioObject (file.get_uri ()); - - string? basename = file.get_basename (); - - if (basename != null) { - audio_object.title = basename; - } else { - audio_object.title = audio_object.uri; - } - - discoverer.discover_uri_async (audio_object.uri); - + var audio_object = new AudioObject.from_file (file); queue_liststore.append (audio_object); } else { invalids++; @@ -145,70 +125,6 @@ public class Music.PlaybackManager : Object { } } - private void update_metadata (Gst.PbUtils.DiscovererInfo info, Error? err) { - string uri = info.get_uri (); - switch (info.get_result ()) { - case Gst.PbUtils.DiscovererResult.URI_INVALID: - critical ("Couldn't read metadata for '%s': invalid URI.", uri); - return; - case Gst.PbUtils.DiscovererResult.ERROR: - critical ("Couldn't read metadata for '%s': %s", uri, err.message); - return; - case Gst.PbUtils.DiscovererResult.TIMEOUT: - critical ("Couldn't read metadata for '%s': Discovery timed out.", uri); - return; - case Gst.PbUtils.DiscovererResult.BUSY: - critical ("Couldn't read metadata for '%s': Already discovering a file.", uri); - return; - case Gst.PbUtils.DiscovererResult.MISSING_PLUGINS: - critical ("Couldn't read metadata for '%s': Missing plugins.", uri); - return; - default: - break; - } - - EqualFunc equal_func = (a, b) => { - return ((AudioObject) a).uri == ((AudioObject) b).uri; - }; - - var temp_audio_object = new AudioObject (uri); - - uint position = -1; - queue_liststore.find_with_equal_func (temp_audio_object, equal_func, out position); - - if (position != -1) { - var audio_object = (AudioObject) queue_liststore.get_item (position); - audio_object.duration = (int64) info.get_duration (); - - unowned Gst.TagList? tag_list = info.get_tags (); - - string _title; - tag_list.get_string (Gst.Tags.TITLE, out _title); - if (_title != null) { - audio_object.title = _title; - } - - string _artist; - tag_list.get_string (Gst.Tags.ARTIST, out _artist); - if (_artist != null) { - audio_object.artist = _artist; - } else if (_title != null) { // Don't set artist for files without tags - audio_object.artist = _("Unknown"); - } - - var sample = get_cover_sample (tag_list); - if (sample != null) { - var buffer = sample.get_buffer (); - - if (buffer != null) { - audio_object.texture = Gdk.Texture.for_pixbuf (get_pixbuf_from_buffer (buffer)); - } - } - } else { - critical ("Couldn't find '%s' in queue", uri); - } - } - private bool bus_callback (Gst.Bus bus, Gst.Message message) { switch (message.type) { case Gst.MessageType.EOS: @@ -355,25 +271,26 @@ public class Music.PlaybackManager : Object { } public int find_title (string term) { - var search_object = new AudioObject ("") { - title = term - }; - - int found_at = -1; - uint position; - if (queue_liststore.find_with_equal_func ( - search_object, - (a, b) => { - var term_a = ((AudioObject)a).title.down (); - var term_b = ((AudioObject)b).title.down (); - return term_a.contains (term_b); - }, - out position - )) { - found_at = (int)position; - } - - return found_at; + return 0; + // var search_object = new AudioObject ("") { + // title = term + // }; + + // int found_at = -1; + // uint position; + // if (queue_liststore.find_with_equal_func ( + // search_object, + // (a, b) => { + // var term_a = ((AudioObject)a).title.down (); + // var term_b = ((AudioObject)b).title.down (); + // return term_a.contains (term_b); + // }, + // out position + // )) { + // found_at = (int)position; + // } + + // return found_at; } private void update_next_previous_sensitivity () { @@ -392,7 +309,7 @@ public class Music.PlaybackManager : Object { } - private Gst.Sample? get_cover_sample (Gst.TagList tag_list) { + public static Gst.Sample? get_cover_sample (Gst.TagList tag_list) { Gst.Sample cover_sample = null; Gst.Sample sample; for (int i = 0; tag_list.get_sample_index (Gst.Tags.IMAGE, i, out sample); i++) { @@ -409,29 +326,4 @@ public class Music.PlaybackManager : Object { return cover_sample; } - - private Gdk.Pixbuf? get_pixbuf_from_buffer (Gst.Buffer buffer) { - Gst.MapInfo map_info; - - if (!buffer.map (out map_info, Gst.MapFlags.READ)) { - warning ("Could not map memory buffer"); - return null; - } - - Gdk.Pixbuf pix = null; - - try { - var loader = new Gdk.PixbufLoader (); - - if (loader.write (map_info.data) && loader.close ()) { - pix = loader.get_pixbuf (); - } - } catch (Error err) { - warning ("Error processing image data: %s", err.message); - } - - buffer.unmap (map_info); - - return pix; - } } diff --git a/src/Views/LibraryView.vala b/src/Views/LibraryView.vala new file mode 100644 index 000000000..716686077 --- /dev/null +++ b/src/Views/LibraryView.vala @@ -0,0 +1,77 @@ +public class Music.LibraryView : Gtk.Box { + private Gtk.Stack placeholder_stack; + private Gtk.SingleSelection selection_model; + + construct { + var library_manager = LibraryManager.get_instance (); + var playback_manager = PlaybackManager.get_default (); + + var placeholder = new Granite.Placeholder (_("No Songs found")) { + description = _("Audio files in your Music directory will appear here"), + icon = new ThemedIcon ("folder-music") + }; + + selection_model = new Gtk.SingleSelection (library_manager.songs) { + can_unselect = true, + autoselect = false + }; + + var factory = new Gtk.SignalListItemFactory (); + factory.setup.connect (setup_widget); + factory.bind.connect (bind_item); + + var list_view = new Gtk.ListView (selection_model, factory) { + hexpand = true + }; + + var scrolled_window = new Gtk.ScrolledWindow () { + child = list_view + }; + + placeholder_stack = new Gtk.Stack (); + placeholder_stack.add_named (scrolled_window, "list-view"); + placeholder_stack.add_named (placeholder, "placeholder"); + placeholder_stack.visible_child_name = "placeholder"; + + var overlay = new Gtk.Overlay () { + child = placeholder_stack + }; + + var loading_overlay_bar = new Granite.OverlayBar (overlay) { + label = _("Discovering Songs"), + active = true + }; + + append (overlay); + + library_manager.get_audio_files.begin (() => loading_overlay_bar.visible = false); + + selection_model.items_changed.connect (update_stack); + update_stack (); + + selection_model.selection_changed.connect (() => { + //TODO: Should clear play queue? + playback_manager.current_audio = (AudioObject)selection_model.get_selected_item (); + }); + + selection_model.set_selected (Gtk.INVALID_LIST_POSITION); + } + + private void update_stack () { + placeholder_stack.visible_child_name = selection_model.get_n_items () > 0 ? "list-view" : "placeholder"; + } + + private void setup_widget (Object obj) { + var list_item = (Gtk.ListItem) obj; + + list_item.child = new TrackRow (); + } + + private void bind_item (Object obj) { + var list_item = (Gtk.ListItem) obj; + + var audio_object = (AudioObject)list_item.item; + + ((TrackRow)list_item.child).bind_audio_object (audio_object); + } +} diff --git a/src/Views/QueueView.vala b/src/Views/QueueView.vala new file mode 100644 index 000000000..abf9e2b10 --- /dev/null +++ b/src/Views/QueueView.vala @@ -0,0 +1,201 @@ +public class Music.QueueView : Gtk.Box { + private Gtk.Button repeat_button; + private Gtk.Button shuffle_button; + private Settings settings; + private Gtk.SearchEntry search_entry; + private Gtk.Revealer search_revealer; + + construct { + var playback_manager = PlaybackManager.get_default (); + + var end_window_controls = new Gtk.WindowControls (END); + + shuffle_button = new Gtk.Button.from_icon_name ("media-playlist-shuffle-symbolic") { + action_name = Application.ACTION_PREFIX + Application.ACTION_SHUFFLE, + tooltip_text = _("Shuffle") + }; + + repeat_button = new Gtk.Button (); + + search_entry = new Gtk.SearchEntry () { + placeholder_text = _("Search titles in playlist") + }; + + search_revealer = new Gtk.Revealer () { + child = search_entry + }; + + playback_manager.bind_property ( + "has-items", search_revealer, "reveal-child", DEFAULT | SYNC_CREATE + ); + + var queue_header = new Gtk.HeaderBar () { + show_title_buttons = false, + title_widget = search_revealer + }; + queue_header.add_css_class (Granite.STYLE_CLASS_DEFAULT_DECORATION); + queue_header.pack_start (shuffle_button); + queue_header.pack_start (repeat_button); + queue_header.pack_end (end_window_controls); + + var queue_placeholder = new Granite.Placeholder (_("Queue is Empty")) { + description = _("Audio files opened from Files will appear here"), + icon = new ThemedIcon ("playlist-queue") + }; + + var queue_listbox = new Gtk.ListBox () { + hexpand = true, + vexpand = true + }; + queue_listbox.bind_model (playback_manager.queue_liststore, create_queue_row); + queue_listbox.set_placeholder (queue_placeholder); + + var scrolled = new Gtk.ScrolledWindow () { + child = queue_listbox + }; + + var add_button_label = new Gtk.Label (_("Open Files…")); + + var add_button_box = new Gtk.Box (HORIZONTAL, 0); + add_button_box.append (new Gtk.Image.from_icon_name ("document-open-symbolic")); + add_button_box.append (add_button_label); + + var add_button = new Gtk.Button () { + child = add_button_box, + action_name = Application.ACTION_PREFIX + Application.ACTION_OPEN + }; + add_button.add_css_class (Granite.STYLE_CLASS_FLAT); + + add_button_label.mnemonic_widget = add_button; + + var action_bar = new Gtk.ActionBar (); + action_bar.pack_start (add_button); + + var toolbar_view = new Adw.ToolbarView () { + bottom_bar_style = RAISED, + content = scrolled + }; + toolbar_view.add_top_bar (queue_header); + toolbar_view.add_bottom_bar (action_bar); + toolbar_view.add_css_class (Granite.STYLE_CLASS_VIEW); + + var drop_target = new Gtk.DropTarget (typeof (Gdk.FileList), Gdk.DragAction.COPY); + scrolled.add_controller (drop_target); + + var error_toast = new Granite.Toast (""); + + var queue_overlay = new Gtk.Overlay () { + child = toolbar_view + }; + queue_overlay.add_overlay (error_toast); + + var queue_handle = new Gtk.WindowHandle () { + child = queue_overlay, + hexpand = true + }; + + hexpand = true; + vexpand = true; + append (queue_handle); + + drop_target.drop.connect ((target, value, x, y) => { + if (value.type () == typeof (Gdk.FileList)) { + File[] files; + SList file_list = null; + foreach (unowned var file in (SList) value.get_boxed ()) { + var file_type = file.query_file_type (FileQueryInfoFlags.NONE); + if (file_type == FileType.DIRECTORY) { + prepend_directory_files (file, ref file_list); + } else { + file_list.prepend (file); + } + } + + file_list.reverse (); + foreach (unowned var file in file_list) { + files += file; + } + + playback_manager.queue_files (files); + + return true; + } + + return false; + }); + + playback_manager.invalids_found.connect ((count) => { + error_toast.title = ngettext ( + "%d invalid file was not added to the queue", + "%d invalid files were not added to the queue", + count).printf (count); + error_toast.send_notification (); + }); + + queue_listbox.row_activated.connect ((row) => { + playback_manager.current_audio = ((TrackRow) row.child).audio_object; + }); + + settings = new Settings ("io.elementary.music"); + settings.changed["repeat-mode"].connect (update_repeat_button); + + update_repeat_button (); + + repeat_button.clicked.connect (() => { + var enum_step = settings.get_enum ("repeat-mode"); + if (enum_step < 2) { + settings.set_enum ("repeat-mode", enum_step + 1); + } else { + settings.set_enum ("repeat-mode", 0); + } + }); + } + + private void update_repeat_button () { + switch (settings.get_string ("repeat-mode")) { + case "disabled": + repeat_button.icon_name = "media-playlist-no-repeat-symbolic"; + repeat_button.tooltip_text = _("Repeat None"); + break; + case "all": + repeat_button.icon_name = "media-playlist-repeat-symbolic"; + repeat_button.tooltip_text = _("Repeat All"); + break; + case "one": + repeat_button.icon_name = "media-playlist-repeat-song-symbolic"; + repeat_button.tooltip_text = _("Repeat One"); + break; + } + } + + //Array concatenation not permitted for parameters so use a list instead + private void prepend_directory_files (GLib.File dir, ref SList file_list) { + try { + var enumerator = dir.enumerate_children ( + "standard::*", + FileQueryInfoFlags.NOFOLLOW_SYMLINKS, + null + ); + + FileInfo info = null; + while ((info = enumerator.next_file (null)) != null) { + var child = dir.resolve_relative_path (info.get_name ()); + if (info.get_file_type () == FileType.DIRECTORY) { + prepend_directory_files (child, ref file_list); + } else { + file_list.prepend (child); + } + } + } catch (Error e) { + warning ("Error while enumerating children of %s: %s", dir.get_uri (), e.message); + } + } + + private Gtk.Widget create_queue_row (GLib.Object object) { + unowned var audio_object = (AudioObject) object; + var track_row = new TrackRow (); + track_row.bind_audio_object (audio_object); + return track_row; + } + +} diff --git a/src/Widgets/TrackRow.vala b/src/Widgets/TrackRow.vala index 4ac199d7c..ad7ad9ec3 100644 --- a/src/Widgets/TrackRow.vala +++ b/src/Widgets/TrackRow.vala @@ -3,17 +3,16 @@ * SPDX-FileCopyrightText: 2021 elementary, Inc. (https://elementary.io) */ -public class Music.TrackRow : Gtk.ListBoxRow { - public AudioObject audio_object { get; construct; } +public class Music.TrackRow : Gtk.Box { + public AudioObject audio_object { get; private set; } private static PlaybackManager playback_manager; + private Gtk.Label title_label; + private Gtk.Label artist_label; + private AlbumImage album_image; private Gtk.Spinner play_icon; - public TrackRow (AudioObject audio_object) { - Object (audio_object: audio_object); - } - static construct { playback_manager = PlaybackManager.get_default (); } @@ -24,17 +23,17 @@ public class Music.TrackRow : Gtk.ListBoxRow { }; play_icon.add_css_class ("play-indicator"); - var album_image = new Music.AlbumImage (); + album_image = new Music.AlbumImage (); album_image.image.height_request = 32; album_image.image.width_request = 32; - var title_label = new Gtk.Label (audio_object.title) { + title_label = new Gtk.Label ("") { ellipsize = Pango.EllipsizeMode.MIDDLE, hexpand = true, xalign = 0 }; - var artist_label = new Gtk.Label (audio_object.artist) { + artist_label = new Gtk.Label ("") { ellipsize = Pango.EllipsizeMode.MIDDLE, hexpand = true, xalign = 0 @@ -54,14 +53,10 @@ public class Music.TrackRow : Gtk.ListBoxRow { grid.attach (artist_label, 1, 1); grid.attach (play_icon, 2, 0, 1, 2); - child = grid; - - audio_object.bind_property ("artist", artist_label, "label", BindingFlags.SYNC_CREATE); - audio_object.bind_property ("title", title_label, "label", BindingFlags.SYNC_CREATE); - audio_object.bind_property ("texture", album_image.image, "paintable", BindingFlags.SYNC_CREATE); + append (grid); playback_manager.notify["current-audio"].connect (() => { - play_icon.spinning = playback_manager.current_audio == audio_object; + play_icon.spinning = audio_object != null ? playback_manager.current_audio == audio_object : false; }); var play_pause_action = (SimpleAction) GLib.Application.get_default ().lookup_action (Application.ACTION_PLAY_PAUSE); @@ -75,6 +70,16 @@ public class Music.TrackRow : Gtk.ListBoxRow { } + public void bind_audio_object (AudioObject audio_object) { + this.audio_object = audio_object; + + title_label.label = audio_object.title; + artist_label.label = audio_object.artist; + album_image.image.paintable = audio_object.texture; + + play_icon.spinning = playback_manager.current_audio == audio_object; + } + private void update_playing (bool playing) { if (playing) { play_icon.add_css_class ("playing"); diff --git a/src/meson.build b/src/meson.build index e4b85f439..44e3a9be4 100644 --- a/src/meson.build +++ b/src/meson.build @@ -2,10 +2,13 @@ sources = [ 'Application.vala', 'AudioObject.vala', 'MainWindow.vala', - 'PlaybackManager.vala', 'DBus/MprisPlayer.vala', 'DBus/MprisRoot.vala', + 'Services/LibraryManager.vala', + 'Services/PlaybackManager.vala', + 'Views/LibraryView.vala', 'Views/NowPlayingView.vala', + 'Views/QueueView.vala', 'Widgets/AlbumImage.vala', 'Widgets/SeekBar.vala', 'Widgets/TrackRow.vala',