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;
+ }
}