diff --git a/meson.build b/meson.build index 3d143de..28eca06 100644 --- a/meson.build +++ b/meson.build @@ -1,7 +1,7 @@ project('wavelet', ['c', 'vala'], - version: '0.1.0', + version: '0.1.0', # WAVELET_VERSION meson_version: '>= 1.0.0', - default_options: [ 'warning_level=none', 'werror=false', ], + default_options: [ 'warning_level=0', 'werror=false', ], ) i18n = import('i18n') diff --git a/src/api.vala b/src/api.vala index 29d5ce9..27d8fa6 100644 --- a/src/api.vala +++ b/src/api.vala @@ -167,6 +167,7 @@ public class Wavelet.Subsonic : Object { this.parameters = @"u=$(Uri.escape_string(username))&p=$(Uri.escape_string(password))&v=1.16.1&c=eu.callcc.Wavelet"; this.session = new Soup.Session (); + this.session.user_agent = "Wavelet/0.1.0 (Linux)"; // WAVELET_VERSION this.artist_list = new ListStore (typeof (Artist)); this.album_list = new ListStore (typeof (Album)); diff --git a/src/application.vala b/src/application.vala index 4df0695..637b27c 100644 --- a/src/application.vala +++ b/src/application.vala @@ -18,7 +18,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -Gst.Element playbin; Wavelet.Subsonic public_api; public class Wavelet.Application : Adw.Application { @@ -41,88 +40,9 @@ public class Wavelet.Application : Adw.Application { public override void activate () { base.activate (); - // FIXME edit back if issues var win = this.active_window ?? new Wavelet.Window (this); - var win = new Wavelet.Window (this); + // if this.active_window not null, this isnt the primary instance + var win = this.active_window ?? new Wavelet.Window (this); win.present (); - - playbin = Gst.ElementFactory.make ("playbin3", null); - assert (playbin != null); - - win.notify["volume"].connect ((s, p) => { - // gst docs: Volume sliders should usually use a cubic volume. - ((Gst.Audio.StreamVolume) playbin).set_volume (Gst.Audio.StreamVolumeFormat.CUBIC, win.volume); - }); - - win.mute.clicked.connect (() => { - var vol = (Gst.Audio.StreamVolume) playbin; - if (vol.get_mute ()) { - win.show_unmute (); - vol.set_mute (false); - } else { - win.show_mute (); - vol.set_mute (true); - } - }); - - Timeout.add (100, () => { - int64 position_ns; - if (playbin.query_position (Gst.Format.TIME, out position_ns)) { - win.play_position_ms = (int) (position_ns / 1000000); - } - return Source.CONTINUE; - }); - - playbin.get_bus ().add_watch (Priority.DEFAULT, (bus, message) => { - switch (message.type) { - case Gst.MessageType.ASYNC_DONE: - win.play_position.sensitive = true; - - int64 duration_ns; - if (playbin.query_duration (Gst.Format.TIME, out duration_ns)) { - win.play_position_ms = 0; - win.play_duration_ms = (int) (duration_ns / 1000000); - } else { - warning ("could not query playbin duration after ASYNC_DONE"); - } - - break; - - default: - break; - } - return true; - }); - - win.setup.connected.connect ((api) => { - public_api = api; - - win.shuffle_all_tracks.sensitive = true; - win.shuffle_all_tracks.activated.connect (() => { - win.shuffle_all_tracks.sensitive = false; - win.play_queue.clear (); - api.get_random_songs.begin (null, (song) => { - win.play_queue.queue (song); - }, (obj, res) => { - try { - api.get_random_songs.end (res); - } catch (Error e) { - error ("could not get random songs: %s", e.message); - } - win.shuffle_all_tracks.sensitive = true; - }); - }); - - win.play_queue.play_now.connect ((song) => { - win.play_position.sensitive = false; - - playbin.set_state (Gst.State.READY); - playbin.set ("uri", api.stream_uri (song.id)); - playbin.set_state (Gst.State.PLAYING); - - win.song = song; - }); - }); - win.setup.load (); } private void on_about_action () { @@ -132,7 +52,7 @@ public class Wavelet.Application : Adw.Application { application_icon = "eu.callcc.Wavelet", developer_name = "Erica Z", translator_credits = _("translator-credits"), - version = "0.1.0", + version = "0.1.0", // WAVELET_VERSION developers = developers, copyright = "© 2024 Erica Z", }; diff --git a/src/meson.build b/src/meson.build index 26fa848..4844168 100644 --- a/src/meson.build +++ b/src/meson.build @@ -3,6 +3,7 @@ wavelet_sources = [ 'application.vala', 'main.vala', 'play_queue.vala', + 'playbin.vala', 'setup.vala', 'window.vala', ] diff --git a/src/play_queue.vala b/src/play_queue.vala index ddc270f..0e9109e 100644 --- a/src/play_queue.vala +++ b/src/play_queue.vala @@ -23,39 +23,40 @@ public class Wavelet.PlayQueue : Adw.NavigationPage { [GtkChild] private unowned Gtk.ListView list_view; private ListStore songs; - private uint current; + private uint next_song; public signal void play_now (Song song); + public signal void now_playing (Song song); + public signal void play_next (Song? song); construct { this.songs = new ListStore (typeof (Song)); - this.current = 0; + this.next_song = 0; this.list_view.model = new Gtk.NoSelection (this.songs); } public void clear () { this.songs.remove_all (); - this.current = 0; } public void queue (Song song) { this.songs.append (song); } - public void next () { - if (this.current < this.songs.get_n_items ()) { - this.current += 1; - } - } - - public Song? peek () { - return (Song?) this.songs.get_item (this.current+1); - } - [GtkCallback] private void on_song_activate (uint position) { - this.current = position; + this.next_song = position; Song song = (Song) this.songs.get_item (position); this.play_now (song); } + + internal void on_stream_start (Playbin playbin) { + Song song = (Song) this.songs.get_item (this.next_song); + this.now_playing (song); + + // prepare for next song gapless + this.next_song += 1; + Song? next_song = (Song?) this.songs.get_item (this.next_song); + this.play_next (next_song); + } } diff --git a/src/playbin.vala b/src/playbin.vala new file mode 100644 index 0000000..ff572c9 --- /dev/null +++ b/src/playbin.vala @@ -0,0 +1,194 @@ +/* playbin.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 + */ + +class Playbin : Object { + // dynamic: undocumented vala feature + // lets us access the about-to-finish signal + private dynamic Gst.Element playbin; + + private SourceFunc? async_done_callback; + + // cubic: recommended for media player volume sliders? + public double volume { + get { + return ((Gst.Audio.StreamVolume) this.playbin).get_volume (Gst.Audio.StreamVolumeFormat.CUBIC); + } + set { + ((Gst.Audio.StreamVolume) this.playbin).set_volume (Gst.Audio.StreamVolumeFormat.CUBIC, value); + } + } + + public bool mute { + get { return playbin.mute; } + set { playbin.mute = value; } + } + + public signal void set_position (int64 position); + public int64 duration { get; private set; } + + public signal void stream_started (); + + construct { + this.playbin = Gst.ElementFactory.make ("playbin3", null); + assert (this.playbin != null); + + //dynamic Gst.Element souphttpsrc = ((Gst.Bin) this.playbin).get_by_name ("souphttpsrc"); + //assert (souphttpsrc != null); + //souphttpsrc.user_agent = "Wavelet/0.1.0 (Linux)"; // WAVELET_VERSION + + // regularly update position + Idle.add (() => { + int64 new_position; + if (this.playbin.query_position (Gst.Format.TIME, out new_position)) { + this.set_position (new_position < this.duration ? new_position : this.duration); + } + + // rerun when idle + return true; + }); + + this.playbin.get_bus ().add_watch (Priority.DEFAULT, (bus, message) => { + // message.type actually seems to be flags + if (Gst.MessageType.ERROR in message.type) { + Error err; + string? debug; + message.parse_error (out err, out debug); + warning ("gst playbin bus error: %s", err.message); + } + + if (Gst.MessageType.ASYNC_DONE in message.type) { + assert (this.async_done_callback != null); + var cb = (owned) this.async_done_callback; + assert (this.async_done_callback == null); // sanity check + cb (); + } + + if (Gst.MessageType.STREAM_START in message.type) { + print ("stream start\n"); + + int64 new_duration; + assert (this.playbin.query_duration (Gst.Format.TIME, out new_duration)); + this.duration = new_duration; + + this.next_uri_lock.lock (); + this.next_uri = null; + this.next_uri_lock.unlock (); + + this.stream_started (); + } + + if (Gst.MessageType.EOS in message.type) { + print ("eos\n"); + } + + return true; + }); + + this.playbin.about_to_finish.connect (this.on_about_to_finish); + } + + private async void set_state (Gst.State state) { + assert (this.async_done_callback == null); + + switch (this.playbin.set_state (state)) { + case Gst.StateChangeReturn.SUCCESS: + break; + + case Gst.StateChangeReturn.ASYNC: + this.async_done_callback = this.set_state.callback; + yield; + break; + + default: + assert (false); + break; + } + } + + public async bool seek (int64 position) { + // don't actually seek if an operation is pending + if (this.async_done_callback != null) { + return false; + } + + // ASSUMPTION: this can only work asynchronously + // (will wait for an ASYNC_DONE in the bus) + assert (this.playbin.seek_simple (Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, position)); + this.async_done_callback = this.seek.callback; + yield; + + return true; + } + + private string? play_now_queued; + // returns true if the "play now" request wasn't overriden by a later call + public async bool play_now (string uri) { + if (this.async_done_callback != null) { + // an operation was already pending + // last writer wins here + this.play_now_queued = uri; + // idle spinning is probably fine + while (this.async_done_callback != null) { + Idle.add (this.play_now.callback); + yield; + // only keep spinning if we can still win + if (this.play_now_queued != uri) { + return false; + } + } + this.play_now_queued = null; + } + + yield this.set_state (Gst.State.READY); + this.playbin.uri = uri; + yield this.set_state (Gst.State.PLAYING); + + if (this.play_now_queued != null) { + // another "play now" was queued while we were busy + // defer to it + return false; + } + + return true; + } + + Mutex next_uri_lock; + string? next_uri; + + public void set_next_uri (string? next_uri) { + this.next_uri_lock.lock (); + this.next_uri = next_uri; + this.next_uri_lock.unlock (); + } + + // called when uri can be switched for gapless playback + // need async queue because this might be called from a gstreamer thread + private void on_about_to_finish (dynamic Gst.Element playbin) { + print("about to finish\n"); + + this.next_uri_lock.lock (); + string? next_uri = this.next_uri; + this.next_uri_lock.unlock (); + + if (next_uri != null) { + playbin.uri = next_uri; + } + } +} diff --git a/src/window.blp b/src/window.blp index c09a8bb..a4c2197 100644 --- a/src/window.blp +++ b/src/window.blp @@ -91,18 +91,8 @@ template $WaveletWindow: Adw.ApplicationWindow { "toolbar", ] - Overlay { - [overlay] Adw.Spinner { - halign: center; - valign: center; - width-request: 20; - height-request: 20; - visible: bind template.cover_art_loading; - } - - Picture { - paintable: bind template.playing_cover_art; - } + Picture { + paintable: bind template.playing_cover_art; } Box { @@ -149,19 +139,20 @@ template $WaveletWindow: Adw.ApplicationWindow { "numeric", ] - label: bind $format_timestamp (template.play_position_ms) as ; + label: bind $format_timestamp (template.position) as ; } Scale play_position { orientation: horizontal; width-request: 200; - sensitive: false; adjustment: Adjustment { lower: 0; - value: bind template.play_position_ms bidirectional; - upper: bind template.play_duration_ms; + value: bind template.position; + upper: bind template.playbin as <$Playbin>.duration; }; + + change-value => $on_play_position_seek (); } Label play_duration { @@ -170,7 +161,7 @@ template $WaveletWindow: Adw.ApplicationWindow { "numeric", ] - label: bind $format_timestamp (template.play_duration_ms) as ; + label: bind $format_timestamp (template.playbin as <$Playbin>.duration) as ; } } } @@ -191,6 +182,8 @@ template $WaveletWindow: Adw.ApplicationWindow { Button { icon-name: "media-playback-start"; valign: center; + + clicked => $on_play_pause_clicked (); } Button { diff --git a/src/window.vala b/src/window.vala index 06074d4..845f469 100644 --- a/src/window.vala +++ b/src/window.vala @@ -30,10 +30,9 @@ public class Wavelet.Window : Adw.ApplicationWindow { [GtkChild] public unowned Adw.ButtonRow shuffle_all_tracks; [GtkChild] public unowned Gtk.Button mute; - [GtkChild] public unowned Gtk.Scale play_position; - - public int play_position_ms { get; set; default = 0; } - public int play_duration_ms { get; set; default = 1; } + + [GtkChild] private unowned Gtk.Scale play_position; + public int64 position { get; private set; } public double volume { get; set; default = 1.0; } @@ -43,11 +42,52 @@ public class Wavelet.Window : Adw.ApplicationWindow { public bool cover_art_loading { get; set; default = false; } public Gdk.Paintable playing_cover_art { get; set; } + internal Playbin playbin { get; default = new Playbin (); } + public Window (Gtk.Application app) { Object (application: app); } construct { + this.setup.connected.connect ((api) => { + public_api = api; + + this.shuffle_all_tracks.sensitive = true; + this.shuffle_all_tracks.activated.connect (() => { + this.shuffle_all_tracks.sensitive = false; + this.play_queue.clear (); + api.get_random_songs.begin (null, (song) => { + this.play_queue.queue (song); + }, (obj, res) => { + try { + api.get_random_songs.end (res); + } catch (Error e) { + error ("could not get random songs: %s", e.message); + } + this.shuffle_all_tracks.sensitive = true; + }); + }); + + playbin.stream_started.connect (this.play_queue.on_stream_start); + + this.play_queue.now_playing.connect ((song) => { + print ("now playing %s\n", song.title); + this.song = song; + }); + + this.play_queue.play_now.connect ((song) => { + playbin.play_now.begin (api.stream_uri (song.id)); + }); + this.play_queue.play_next.connect ((song) => { + if (song == null) { + playbin.set_next_uri (null); + } else { + playbin.set_next_uri (api.stream_uri (song.id)); + } + }); + }); + this.setup.load (); + this.sidebar.select_row (this.sidebar.get_row_at_index (0)); this.notify["song"].connect (() => { @@ -74,7 +114,14 @@ public class Wavelet.Window : Adw.ApplicationWindow { }); } }); - this.set("song", null); + this.song = null; + + this.playbin.set_position.connect ((sender, new_position) => { + // only set if we aren't seeking + if (this.seek_timeout_id == 0) { + this.position = new_position; + } + }); } public void show_mute () { @@ -93,8 +140,26 @@ public class Wavelet.Window : Adw.ApplicationWindow { } } - [GtkCallback] private string format_timestamp (int ms) { - int s = ms / 1000; + [GtkCallback] private string format_timestamp (int64 ns) { + int64 ms = ns / 1000000; + int s = (int) (ms / 1000); return "%02d:%02d".printf (s/60, s%60); } + + [GtkCallback] private void on_play_pause_clicked () { + } + + // same timeout logic as https://code.videolan.org/videolan/npapi-vlc/blob/6eae0ffb9cbaf8f6e04423de2ff38daabdf7cae3/npapi/vlcplugin_gtk.cpp#L312 + private uint seek_timeout_id = 0; + [GtkCallback] private bool on_play_position_seek (Gtk.Range range, Gtk.ScrollType scroll_type, double value) { + if (this.seek_timeout_id == 0) { + this.seek_timeout_id = Timeout.add (500, () => { + playbin.seek.begin ((int64) range.adjustment.value, (obj, res) => { + this.seek_timeout_id = 0; + }); + return false; + }); + } + return false; + } }