diff --git a/src/api.vala b/src/api.vala index 27d8fa6..a8c0642 100644 --- a/src/api.vala +++ b/src/api.vala @@ -31,7 +31,6 @@ public class Wavelet.Artist : Object, Json.Serializable { public string? cover_art; public string? artist_image_url; public int64 album_count; - public DateTime? starred; public Artist (string index, Json.Reader reader) { this.index = index; @@ -55,12 +54,6 @@ public class Wavelet.Artist : Object, Json.Serializable { reader.read_member ("albumCount"); this.album_count = reader.get_int_value (); reader.end_member (); - - reader.read_member ("starred"); - if (reader.is_value ()) { - this.starred = new DateTime.from_iso8601 (reader.get_string_value (), null); - } - reader.end_member (); } } @@ -86,6 +79,7 @@ public class Wavelet.Song : Object { public string artist { get; private set; } public int64 track { get; private set; } public int64 year { get; private set; } + public DateTime? starred { get; private set; } public Song (Json.Reader reader) { reader.read_member ("id"); diff --git a/src/meson.build b/src/meson.build index 4844168..4ee8d3c 100644 --- a/src/meson.build +++ b/src/meson.build @@ -2,6 +2,7 @@ wavelet_sources = [ 'api.vala', 'application.vala', 'main.vala', + 'mpris.vala', 'play_queue.vala', 'playbin.vala', 'setup.vala', diff --git a/src/mpris.vala b/src/mpris.vala new file mode 100644 index 0000000..6c9e6a6 --- /dev/null +++ b/src/mpris.vala @@ -0,0 +1,85 @@ +/* play_queue.vala + * + * Copyright 2024 Erica Z + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +[DBus (name = "org.mpris.MediaPlayer2")] +class Mpris : Object { + internal signal void on_raise (); + internal signal void on_quit (); + + public bool can_raise { get { return true; } } + public void raise () throws Error { + this.on_raise (); + } + + public bool can_quit { get { return true; } } + public void quit () throws Error { + this.on_quit (); + } + + public bool can_set_fullscreen { get { return false; } } + public bool fullscreen { get { return false; } set { assert (false); } } + public bool has_track_list { get { return false; } } + public string identity { owned get { return "Wavelet"; } } + public string desktop_entry { owned get { return "wavelet"; } } + public string[] supported_uri_schemes { owned get { return {}; } } + public string[] supported_mime_types { owned get { return {}; } } +} + +[DBus (name = "org.mpris.MediaPlayer2.Player")] +class MprisPlayer : Object { + internal signal void on_next (); + internal signal void on_previous (); + internal signal void on_pause (); + internal signal void on_play_pause (); + internal signal void on_stop (); + internal signal void on_play (); + internal signal void on_seek (int64 offset); + internal signal void on_set_position (string track_id, int64 position); + + public void next () throws Error { this.on_next (); } + public void previous () throws Error { this.on_previous (); } + public void pause () throws Error { print("pause\n");this.on_pause (); } + public void play_pause () throws Error { this.on_play_pause (); } + public void stop () throws Error { this.on_stop (); } + public void play () throws Error { this.on_play (); } + public void seek (int64 offset) throws Error { this.on_seek (offset); } + public void set_position (string track_id, int64 position) throws Error { this.on_set_position (track_id, position); } + public void open_uri (string uri) throws Error { assert (false); } + + public signal void seeked (int64 position); + + public string playback_status { owned get; internal set; default = "Stopped"; } + public string loop_status { owned get; set; default = "None"; } + public double rate { get; set; default = 1.0; } + public bool shuffle { get; set; default = false; } + public HashTable metadata_map { owned get; default = new HashTable(null, null); } + public double volume { get; set; default = 1.0; } + [CCode (notify = false)] + public int64 position { get; default = 0; } + public double minimum_rate { get { return 1.0; } } + public double maximum_rate { get { return 1.0; } } + public bool can_go_next { get; default = false; } + public bool can_go_previous { get; default = false; } + public bool can_play { get; default = false; } + public bool can_pause { get; default = false; } + public bool can_seek { get; default = false; } + [CCode (notify = false)] + public bool can_control { get { return false; } } +} diff --git a/src/play_queue.blp b/src/play_queue.blp index 4f5d971..ef57322 100644 --- a/src/play_queue.blp +++ b/src/play_queue.blp @@ -6,28 +6,61 @@ template $WaveletPlayQueue: Adw.NavigationPage { Adw.ToolbarView { [top] - Adw.HeaderBar {} + Adw.HeaderBar { + Button { + icon-name: "edit-clear-all"; + clicked => $clear (); + sensitive: bind template.can_clear_all; + } + } ScrolledWindow { - ListView list_view { - single-click-activate: true; - show-separators: true; + ColumnView { + styles [ "data-table" ] - activate => $on_song_activate (); - - model: Gtk.NoSelection { + model: SingleSelection selection { model: bind template.songs; + selected: bind template.selected_index; + selection-changed => $on_song_selected (); }; - factory: BuilderListItemFactory { - template ListItem { - child: Label { - styles [ "bold" ] - halign: start; - label: bind template.item as <$WaveletSong>.title; - }; - } - }; + ColumnViewColumn { + factory: SignalListItemFactory { + setup => $delete_cell_setup (); + }; + } + + ColumnViewColumn { + title: _("Title"); + expand: true; + + factory: BuilderListItemFactory { + template ColumnViewCell { + child: Label { + halign: start; + label: bind template.item as <$WaveletSong>.title; + tooltip-text: bind template.item as <$WaveletSong>.title; + ellipsize: end; + }; + } + }; + } + + ColumnViewColumn { + title: _("Artist"); + fixed-width: 200; + + factory: BuilderListItemFactory { + template ColumnViewCell { + child: Label { + halign: start; + label: bind template.item as <$WaveletSong>.artist; + tooltip-text: bind template.item as <$WaveletSong>.artist; + ellipsize: end; + }; + } + }; + } } } } diff --git a/src/play_queue.vala b/src/play_queue.vala index bdb4733..7c3129e 100644 --- a/src/play_queue.vala +++ b/src/play_queue.vala @@ -22,6 +22,7 @@ public class Wavelet.PlayQueue : Adw.NavigationPage { public ListStore songs { get; private set; } + public uint selected_index { get; set; } // this is the index of the song that will play on next on_stream_start private uint next_stream_index; @@ -30,13 +31,20 @@ public class Wavelet.PlayQueue : Adw.NavigationPage { public signal void now_playing (Song song); + private bool ignore_selection = false; + + [GtkChild] private unowned Gtk.SingleSelection selection; + + public bool can_clear_all { get; private set; default = false; } + construct { this.songs = new ListStore (typeof (Song)); this.next_stream_index = 0; } - public void clear () { + [GtkCallback] public void clear () { this.songs.remove_all (); + this.can_clear_all = false; } public void queue (Song song) { @@ -45,19 +53,36 @@ public class Wavelet.PlayQueue : Adw.NavigationPage { if (new_index == next_stream_index) { this.play_next (song); } + this.can_clear_all = true; } - [GtkCallback] private void on_song_activate (uint position) { - this.next_stream_index = position; - Song song = (Song) this.songs.get_item (position); - this.play_next (song); - this.play_now (song); + [GtkCallback] private void on_song_selected () { + this.selected_index = this.selection.selected; // manual bidi binding + if (this.ignore_selection) return; + this.pick_song (this.selected_index); + } + + private void pick_song (uint index) { + Song? song = (Song?) this.songs.get_item (index); + if (song != null) { + this.ignore_selection = true; + this.selected_index = index; + this.ignore_selection = false; + + this.next_stream_index = index; + this.play_next (song); + this.play_now (song); + } } internal void on_stream_start (Playbin playbin) { Song song = (Song) this.songs.get_item (this.next_stream_index); this.now_playing (song); + this.ignore_selection = true; + this.selected_index = this.next_stream_index; + this.ignore_selection = false; + // prepare for next song ahead of time (gapless) this.next_stream_index += 1; Song? next_song = (Song?) this.songs.get_item (this.next_stream_index); @@ -65,25 +90,27 @@ public class Wavelet.PlayQueue : Adw.NavigationPage { } internal void restart () { - this.next_stream_index = 0; - Song song = (Song) this.songs.get_item (0); - this.play_next (song); - this.play_now (song); + this.pick_song (0); } public void skip_forward () { - Song song = (Song) this.songs.get_item (this.next_stream_index); - this.play_now (song); + this.pick_song (this.selected_index + 1); } public void skip_backward () { - if (this.next_stream_index <= 1) { - this.next_stream_index = 0; - } else { - this.next_stream_index -= 2; + if (this.selected_index >= 1) { + this.pick_song (this.selected_index - 1); } - Song song = (Song) this.songs.get_item (this.next_stream_index); - this.play_next (song); - this.play_now (song); + } + + [GtkCallback] private void delete_cell_setup (Object object) { + Gtk.ColumnViewCell cell = (Gtk.ColumnViewCell) object; + Gtk.Button button = new Gtk.Button.from_icon_name ("edit-delete"); + button.add_css_class ("flat"); + cell.child = button; + button.clicked.connect (() => { + this.songs.remove (cell.position); + this.can_clear_all = this.songs.get_n_items() > 0; + }); } } diff --git a/src/playbin.vala b/src/playbin.vala index f21d5fc..3c1dded 100644 --- a/src/playbin.vala +++ b/src/playbin.vala @@ -174,6 +174,7 @@ class Playbin : Object { // pretend this track was locked in by about-to-finish before this.next_uri_lock.lock (); + this.next_uri = uri; this.next_uri_locked_in = uri; this.next_uri_lock.unlock (); diff --git a/src/window.blp b/src/window.blp index 033c544..b8e0fb8 100644 --- a/src/window.blp +++ b/src/window.blp @@ -200,9 +200,11 @@ template $WaveletWindow: Adw.ApplicationWindow { valign: center; } - Button mute { - icon-name: "audio-volume-high"; + Button { + icon-name: bind $mute_button_icon_name (template.mute) as ; valign: center; + + clicked => $on_mute_toggle (); } Scale { diff --git a/src/window.vala b/src/window.vala index 2ecf56c..cdc2786 100644 --- a/src/window.vala +++ b/src/window.vala @@ -19,7 +19,7 @@ */ [GtkTemplate (ui = "/eu/callcc/Wavelet/window.ui")] -public class Wavelet.Window : Adw.ApplicationWindow { +class Wavelet.Window : Adw.ApplicationWindow { [GtkChild] private unowned Gtk.ListBox sidebar; [GtkChild] private unowned Gtk.ListBoxRow sidebar_setup; [GtkChild] private unowned Gtk.ListBoxRow sidebar_play_queue; @@ -29,14 +29,19 @@ public class Wavelet.Window : Adw.ApplicationWindow { [GtkChild] public unowned Wavelet.PlayQueue play_queue; [GtkChild] public unowned Adw.ButtonRow shuffle_all_tracks; - [GtkChild] public unowned Gtk.Button mute; - public bool playing { get; private set; default = false; } [GtkChild] private unowned Gtk.Scale play_position; public int64 position { get; private set; } - public double volume { get; set; default = 1.0; } + public double volume { + get { return this.playbin.volume; } + set { this.playbin.volume = value; } + } + public bool mute { + get { return this.playbin.mute; } + set { this.playbin.mute = value; } + } public Song? song { get; set; default = null; } @@ -49,9 +54,28 @@ public class Wavelet.Window : Adw.ApplicationWindow { public Window (Gtk.Application app) { Object (application: app); + + this.close_request.connect (() => { + app.quit (); + return false; + }); } construct { + Bus.own_name ( + BusType.SESSION, + "org.mpris.MediaPlayer2.wavelet", + BusNameOwnerFlags.NONE, + (conn) => { + try { + // TODO: mpris + } catch (IOError e) { + error ("could not register dbus service: %s", e.message); + } + }, + () => {}, + () => { error ("could not acquire dbus name"); }); + this.setup.connected.connect ((api) => { public_api = api; @@ -129,14 +153,6 @@ public class Wavelet.Window : Adw.ApplicationWindow { }); } - public void show_mute () { - this.mute.icon_name = "audio-volume-muted"; - } - - public void show_unmute () { - this.mute.icon_name = "audio-volume-high"; - } - [GtkCallback] private void on_sidebar_row_activated (Gtk.ListBoxRow row) { if (row == this.sidebar_setup) { this.stack.set_visible_child_name("setup"); @@ -179,6 +195,14 @@ public class Wavelet.Window : Adw.ApplicationWindow { return playing ? "media-playback-pause" : "media-playback-start"; } + [GtkCallback] private string mute_button_icon_name (bool mute) { + return mute ? "audio-volume-muted" : "audio-volume-high"; + } + + [GtkCallback] private void on_mute_toggle () { + this.mute = !this.mute; + } + [GtkCallback] private void on_skip_forward_clicked () { this.play_queue.skip_forward (); }