From 925367b180ec2749648bc0ef8055a1d11c66edf3 Mon Sep 17 00:00:00 2001 From: Erica Z Date: Thu, 17 Oct 2024 12:05:20 +0200 Subject: [PATCH] replace gstreamer with mpv lol --- src/main.vala | 2 - src/meson.build | 3 +- src/playbin.vala | 449 +++++++++++++++++++++++---------------------- src/ui/window.blp | 10 +- src/ui/window.vala | 38 ++-- src/vapi/mpv.vapi | 101 ++++++++++ 6 files changed, 351 insertions(+), 252 deletions(-) create mode 100644 src/vapi/mpv.vapi diff --git a/src/main.vala b/src/main.vala index c77eefa..a2c2204 100644 --- a/src/main.vala +++ b/src/main.vala @@ -3,8 +3,6 @@ int main (string[] args) { Intl.bind_textdomain_codeset (Audrey.Config.GETTEXT_PACKAGE, "UTF-8"); Intl.textdomain (Audrey.Config.GETTEXT_PACKAGE); - Gst.init (ref args); - var app = new Audrey.Application (); return app.run (args); } diff --git a/src/meson.build b/src/meson.build index 1c74ba7..4704f2e 100644 --- a/src/meson.build +++ b/src/meson.build @@ -12,14 +12,13 @@ audrey_sources = [ audrey_deps = [ config_dep, - dependency('gstreamer-1.0', version: '>= 1.24'), - dependency('gstreamer-audio-1.0', version: '>= 1.24'), dependency('gtk4', version: '>= 4.16'), dependency('json-glib-1.0', version: '>= 1.10'), dependency('libadwaita-1', version: '>= 1.6'), dependency('libgcrypt', version: '>= 1.11'), dependency('libsecret-1', version: '>= 0.21'), dependency('libsoup-3.0', version: '>= 3.6'), + dependency('mpv', version: '>= 2.3'), ] subdir('ui') diff --git a/src/playbin.vala b/src/playbin.vala index 38be147..94f5b16 100644 --- a/src/playbin.vala +++ b/src/playbin.vala @@ -4,269 +4,282 @@ enum PlaybinState { PLAYING, } +errordomain PlaybinError { + MPV, +} + +private void check_mpv_error (int ec) throws PlaybinError { + if (ec < 0) { + throw new PlaybinError.MPV ("%s", Mpv.error_string (ec)); + } +} + +private class SourceFuncWrapper { + public SourceFunc inner; + + public SourceFuncWrapper () { + this.inner = null; + } +} + class Playbin : GLib.Object { - // dynamic: undocumented vala feature - // lets us access the about-to-finish signal - private dynamic Gst.Element playbin = Gst.ElementFactory.make ("playbin3", null); - - 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 this.playbin.mute; } - set { this.playbin.mute = value; } - } + private Mpv.Handle mpv = new Mpv.Handle (); public PlaybinState state { get; private set; default = PlaybinState.STOPPED; } - public Subsonic.Song? current_song { get; private set; default = null; } - - public int64 position { get; private set; default = 0; } - public int64 duration { get; private set; default = 1; } // if 0, the seekbar vanishes - - public Subsonic.Client api { get; set; default = null; } - - // sent when a new song starts playing - // continues: whether the track is a gapless continuation - public signal void now_playing (bool continues); - public signal void stopped (); - - // the index of the track in the play queue that is currently playing - // must equal play queue len iff state is STOPPED - public uint current_position { get; private set; } - - // whether we are expecting a gapless continuation next - private bool next_gapless; - - private void source_setup (Gst.Element playbin, dynamic Gst.Element source) { - source.user_agent = Audrey.Const.user_agent; - } - - // ASSUMPTION: about-to-finish will be signalled exactly once per track - // even if seeking backwards after - private GLib.AsyncQueue next_uri = new GLib.AsyncQueue (); - - private ListModel _play_queue = null; - private ulong _play_queue_items_changed; - public ListModel play_queue { - get { return _play_queue; } + private int _volume = 100; + public int volume { + get { return _volume; } set { - if (_play_queue != null) { - SignalHandler.disconnect (_play_queue, _play_queue_items_changed); - } - _play_queue = value; - _play_queue_items_changed = value.items_changed.connect (on_play_queue_items_changed); + _volume = value; + mpv_set_property_int64.begin ("volume", value); } } - // called when uri can be switched for gapless playback - // need async queue because this might be called from a gstreamer thread - private void about_to_finish (dynamic Gst.Element playbin) { - print ("about to finish\n"); + public bool _mute = false; + public bool mute { + get { return _mute; } + set { + _mute = value; + mpv_set_property_flag.begin ("mute", value); + } + } - // will block if the next uri isn't ready yet - // leaves the queue empty as per the ASSUMPTION above - string? next_uri = this.next_uri.pop (); + public uint play_queue_position { get; private set; } + public Subsonic.Song? song { get; private set; } + public signal void now_playing (); - if (next_uri != "") { - playbin.uri = next_uri; + public double position { get; private set; default = 0.0; } + public double duration { get; private set; default = 0.0; } + + public Subsonic.Client api { get; set; default = null; } + + private ListModel _play_queue = null; + public ListModel play_queue { + get { return _play_queue; } + set { + assert (_play_queue == null); // only set this once + _play_queue = value; + value.items_changed.connect (on_play_queue_items_changed); } } private void on_play_queue_items_changed (ListModel play_queue, uint position, uint removed, uint added) { - if (this.state == PlaybinState.STOPPED) { - return; + // FIXME: these should prolly be chained + + for (uint i = 0; i < removed; i += 1) { + this.mpv_command.begin ({"playlist-remove", position.to_string ()}); } - if (this.current_position >= position) { - if (this.current_position < position+removed) { - // current track was removed, start playing something else - // TODO check if it was actually reordered - - if (position == play_queue.get_n_items ()) { - this.stop (); - } else { - this.select_track (position); - } - } else { - // unaffected - // fix up playing index though - this.current_position = this.current_position + added - removed; - } - } else if (this.current_position+1 == position) { - // next track was changed - // try to fix up gapless transition - string? next_uri = this.next_uri.try_pop (); - if (next_uri != null) { - // we're in luck, about-to-finish hasn't been triggered yet - // we can get away with replacing it - if (this.current_position+1 < play_queue.get_n_items ()) { - var song = (Subsonic.Song) play_queue.get_item (this.current_position+1); - this.next_uri.push (this.api.stream_uri (song.id)); - } else { - this.next_uri.push (""); - } - } else { - // about-to-finish already triggered - // we'll need to stop the new track when it starts playing - // but stream-start should already be able to take care of that - } + for (uint i = 0; i < added; i += 1) { + this.mpv_command.begin ({"loadfile", this.api.stream_uri (((Subsonic.Song) play_queue.get_item (position+i)).id), "insert-at-play", (position+i).to_string ()}); } } - private bool queued_seek = false; + private SourceFuncWrapper[] mpv_command_callbacks = {}; + private int[] mpv_command_error = {}; + + private async void mpv_command (string[] args) throws Error { + int userdata = -1; + for (int i = 0; i < this.mpv_command_callbacks.length; i += 1) { + if (this.mpv_command_callbacks[i].inner == null) { + userdata = i; + break; + } + } + if (userdata == -1) { + userdata = this.mpv_command_callbacks.length; + this.mpv_command_callbacks += new SourceFuncWrapper (); + this.mpv_command_error += 0; + } + + check_mpv_error (this.mpv.command_async ((uint64) userdata, args)); + this.mpv_command_callbacks[userdata].inner = this.mpv_command.callback; + yield; + + check_mpv_error (this.mpv_command_error[userdata]); + } + + private SourceFuncWrapper[] mpv_set_property_callbacks = {}; + private int[] mpv_set_property_error = {}; + + private async void mpv_set_property (string name, Mpv.Format format, void *data) throws Error { + int userdata = -1; + for (int i = 0; i < this.mpv_set_property_callbacks.length; i += 1) { + if (this.mpv_set_property_callbacks[i].inner == null) { + userdata = i; + break; + } + } + if (userdata == -1) { + userdata = this.mpv_set_property_callbacks.length; + this.mpv_set_property_callbacks += new SourceFuncWrapper (); + this.mpv_set_property_error += 0; + } + + check_mpv_error (this.mpv.set_property_async ((uint64) userdata, name, format, data)); + this.mpv_set_property_callbacks[userdata].inner = this.mpv_set_property.callback; + yield; + + check_mpv_error (this.mpv_set_property_error[userdata]); + } + + private async void mpv_set_property_int64 (string name, int64 value) throws Error { + yield this.mpv_set_property (name, Mpv.Format.INT64, &value); + } + + private async void mpv_set_property_flag (string name, bool value) throws Error { + int flag = value ? 1 : 0; + yield this.mpv_set_property (name, Mpv.Format.FLAG, &flag); + } public Playbin () { - this.next_uri.push (""); + try { + check_mpv_error (this.mpv.initialize ()); + check_mpv_error (this.mpv.set_property_string ("user-agent", Audrey.Const.user_agent)); + check_mpv_error (this.mpv.set_property_string ("video", "no")); + check_mpv_error (this.mpv.set_property_string ("prefetch-playlist", "yes")); + check_mpv_error (this.mpv.set_property_string ("gapless-audio", "yes")); + check_mpv_error (this.mpv.observe_property (0, "time-pos", Mpv.Format.DOUBLE)); + check_mpv_error (this.mpv.observe_property (1, "duration", Mpv.Format.DOUBLE)); + check_mpv_error (this.mpv.observe_property (2, "playlist-pos", Mpv.Format.INT64)); + } catch (Error e) { + error ("could not initialize mpv: %s", e.message); + } - this.playbin.source_setup.connect (this.source_setup); - this.playbin.about_to_finish.connect (this.about_to_finish); + this.mpv.wakeup_callback = () => { + Idle.add (() => { + while (true) { + var event = this.mpv.wait_event (0.0); + if (event.event_id == Mpv.EventId.NONE) break; - // regularly update position - Timeout.add (100, () => { - if (this.queued_seek) { - if (this.playbin.seek_simple (Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE, position)) { - this.queued_seek = false; + switch (event.event_id) { + case Mpv.EventId.COMMAND_REPLY: + this.mpv_command_error[event.reply_userdata] = event.error; + var cb = (owned) this.mpv_command_callbacks[event.reply_userdata].inner; + cb (); + break; + + case Mpv.EventId.SET_PROPERTY_REPLY: + this.mpv_set_property_error[event.reply_userdata] = event.error; + var cb = (owned) this.mpv_set_property_callbacks[event.reply_userdata].inner; + cb (); + break; + + case Mpv.EventId.PROPERTY_CHANGE: + switch (event.reply_userdata) { + case 0: + var data = (Mpv.EventProperty *) event.data; + assert (data.name == "time-pos"); + if (data.format == Mpv.Format.NONE) { + this.position = 0.0; + } else { + assert (data.format == Mpv.Format.DOUBLE); + this.position = * (double *) data.data; + } + break; + + case 1: + var data = (Mpv.EventProperty *) event.data; + assert (data.name == "duration"); + if (data.format == Mpv.Format.NONE) { + this.duration = 0.0; + } else { + assert (data.format == Mpv.Format.DOUBLE); + this.duration = * (double *) data.data; + } + break; + + case 2: + var data = (Mpv.EventProperty *) event.data; + assert (data.name == "playlist-pos"); + if (data.format == Mpv.Format.NONE) { + this.play_queue_position = 0; + } else { + assert (data.format == Mpv.Format.INT64); + this.play_queue_position = (uint) * (int64 *) data.data; + } + break; + + default: + assert (false); + break; + } + break; + + case Mpv.EventId.START_FILE: + // ignore + break; + + case Mpv.EventId.FILE_LOADED: + this.song = (Subsonic.Song) this.play_queue.get_item (this.play_queue_position); + this.now_playing (); + break; + + case Mpv.EventId.PLAYBACK_RESTART: + // ignore + break; + + case Mpv.EventId.SEEK: + // ignore + break; + + case Mpv.EventId.END_FILE: + // ignore + break; + + // deprecated, ignore + case Mpv.EventId.IDLE: + case Mpv.EventId.TICK: + // uninteresting, ignore + case Mpv.EventId.AUDIO_RECONFIG: + break; + + default: + print ("got unimplemented %s\n", event.event_id.to_string ()); + break; + } } - } else { - int64 new_position; - if (this.playbin.query_position (Gst.Format.TIME, out new_position)) { - if (new_position > this.duration) this.position = this.duration; - else this.position = new_position; - } - } - - // keep rerunning - return true; - }); - - var bus = this.playbin.get_bus (); - bus.add_signal_watch (); - - bus.message["error"].connect ((message) => { - Error err; - string? debug; - message.parse_error (out err, out debug); - error ("gst playbin bus error: %s", err.message); - }); - - bus.message["warning"].connect ((message) => { - Error err; - string? debug; - message.parse_error (out err, out debug); - warning ("gst playbin bus warning: %s", err.message); - }); - - bus.message["stream-start"].connect ((message) => { - int64 duration; - assert (this.playbin.query_duration (Gst.Format.TIME, out duration)); - this.duration = duration; - - // cancel any queued seeks - this.queued_seek = false; - this.position = 0; - - bool continues = this.next_gapless; - if (this.next_gapless) { - // advance position in play queue - this.current_position += 1; - } else { - this.next_gapless = true; - } - - var now_playing = (Subsonic.Song) play_queue.get_item (this.current_position); - if (this.api.stream_uri (now_playing.id) == (string) this.playbin.current_uri) { - if (continues) { - this.current_song = now_playing; - this.now_playing (true); - } - - if (this.current_position+1 < play_queue.get_n_items ()) { - var song = (Subsonic.Song) play_queue.get_item (this.current_position+1); - this.next_uri.push (this.api.stream_uri (song.id)); - } else { - this.next_uri.push (""); - } - } else { - // edge case - // just flush everything and pray next stream-start is fine - this.select_track (this.current_position); - } - }); - - bus.message["state-changed"].connect ((message) => { - if (message.src != this.playbin) return; - - Gst.State new_state; - message.parse_state_changed (null, out new_state, null); - }); - - bus.message["eos"].connect ((message) => { - this.stop (); - }); + + return false; + }); + }; } - public void seek (int64 position) { + public void seek (double position) { this.position = position; - - if (!this.queued_seek) { - if (!this.playbin.seek_simple (Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE, position)) { - this.queued_seek = true; - } - } + this.mpv_command.begin ({"seek", position.to_string (), "absolute"}); } // manually changes which track in the play queue to play public void select_track (uint position) requires (position < this.play_queue.get_n_items ()) { + this.mpv_command.begin ({"playlist-play-index", position.to_string ()}); this.state = PlaybinState.PLAYING; - - this.current_position = position; - this.playbin.set_state (Gst.State.READY); - var song = (Subsonic.Song) this.play_queue.get_item (position); - this.playbin.uri = this.api.stream_uri (song.id); - this.playbin.set_state (Gst.State.PLAYING); - this.next_gapless = false; - - // make sure the queue is empty, so next stream-changed can fix it up - this.next_uri.try_pop (); - // if it was already empty then uhhhh if theres any problems then - // playbin.uri wont match up with the current track's stream uri and we can - // fix it there - - this.current_song = song; - this.position = 0; - this.duration = song.duration * Gst.SECOND - 1; - this.now_playing (false); } public void pause () { assert (this.state != PlaybinState.STOPPED); - this.playbin.set_state (Gst.State.PAUSED); this.state = PlaybinState.PAUSED; + this.mpv_command.begin ({"pause"}); } public void play () { assert (this.state != PlaybinState.STOPPED); - this.playbin.set_state (Gst.State.PLAYING); this.state = PlaybinState.PLAYING; + this.mpv_command.begin ({"play"}); } - public void stop () { - this.playbin.set_state (Gst.State.READY); - this.state = PlaybinState.STOPPED; - this.current_position = this.play_queue.get_n_items (); - this.stopped (); - - this.position = 0; - this.duration = 1; - this.current_song = null; + public void next_track () { + assert (this.state != PlaybinState.STOPPED); + this.state = PlaybinState.PLAYING; + this.mpv_command.begin ({"playlist-next-playlist"}); + } + + public void prev_track () { + assert (this.state != PlaybinState.STOPPED); + this.state = PlaybinState.PLAYING; + this.mpv_command.begin ({"playlist-prev-playlist"}); } } diff --git a/src/ui/window.blp b/src/ui/window.blp index 3bf3376..fc925e2 100644 --- a/src/ui/window.blp +++ b/src/ui/window.blp @@ -103,21 +103,21 @@ template $UiWindow: Adw.ApplicationWindow { styles [ "heading" ] xalign: 0; halign: start; - label: bind $song_title (template.playbin as <$Playbin>.current_song) as ; + label: bind $song_title (template.playbin as <$Playbin>.song) as ; ellipsize: end; } Label { styles [ "caption" ] xalign: 0; - label: bind $song_artist (template.playbin as <$Playbin>.current_song) as ; + label: bind $song_artist (template.playbin as <$Playbin>.song) as ; ellipsize: end; } Label { styles [ "caption" ] xalign: 0; - label: bind $song_album (template.playbin as <$Playbin>.current_song) as ; + label: bind $song_album (template.playbin as <$Playbin>.song) as ; ellipsize: end; } } @@ -232,9 +232,9 @@ template $UiWindow: Adw.ApplicationWindow { width-request: 130; adjustment: Adjustment { - lower: 0.0; + lower: 0; value: bind template.volume bidirectional; - upper: 1.0; + upper: 100; }; } } diff --git a/src/ui/window.vala b/src/ui/window.vala index b0dcba6..3fe17d1 100644 --- a/src/ui/window.vala +++ b/src/ui/window.vala @@ -11,7 +11,7 @@ class Ui.Window : Adw.ApplicationWindow { private Subsonic.Client api; - public double volume { + public int volume { get { return this.playbin.volume; } set { this.playbin.volume = value; } } @@ -54,12 +54,9 @@ class Ui.Window : Adw.ApplicationWindow { this.api = api; this.playbin.api = api; - this.playbin.now_playing.connect ((continues) => { - api.scrobble.begin (playbin.current_song.id); - this.play_queue.selection.playbin_select (playbin.current_position); - }); - this.playbin.stopped.connect (() => { - this.play_queue.selection.playbin_select (this.play_queue_store.get_n_items ()); + this.playbin.now_playing.connect (() => { + api.scrobble.begin (playbin.song.id); + this.play_queue.selection.playbin_select (playbin.play_queue_position); }); this.play_queue.selection.user_selected.connect ((position) => { @@ -95,10 +92,10 @@ class Ui.Window : Adw.ApplicationWindow { this.cancel_loading_art = new GLib.Cancellable (); this.playing_cover_art = Gdk.Paintable.empty (1, 1); - if (playbin.current_song != null) { + if (playbin.song != null) { this.cover_art_loading = true; - string song_id = playbin.current_song.id; + string song_id = playbin.song.id; this.api.cover_art.begin (song_id, this.cancel_loading_art, (obj, res) => { try { this.playing_cover_art = Gdk.Texture.for_pixbuf (this.api.cover_art.end (res)); @@ -120,13 +117,8 @@ class Ui.Window : Adw.ApplicationWindow { } } - [GtkCallback] private string format_timestamp (int64 ns) { - if (ns == 1) { - // treat 1 nanosecond as a sentinel value - return "-"; - } - int s = (int) (ns / Gst.SECOND); - return "%02d:%02d".printf (s/60, s%60); + [GtkCallback] private string format_timestamp (double s) { + return "%02d:%02d".printf (((int) s)/60, ((int) s)%60); } [GtkCallback] private bool on_play_position_seek (Gtk.Range range, Gtk.ScrollType scroll_type, double value) { @@ -163,15 +155,11 @@ class Ui.Window : Adw.ApplicationWindow { } [GtkCallback] private void on_skip_forward_clicked () { - if (this.playbin.current_position+1 < this.playbin.play_queue.get_n_items ()) { - this.playbin.select_track (this.playbin.current_position+1); - } + this.playbin.next_track (); } [GtkCallback] private void on_skip_backward_clicked () { - if (this.playbin.current_position > 0) { - this.playbin.select_track (this.playbin.current_position-1); - } + this.playbin.prev_track (); } [GtkCallback] private void show_setup_dialog () { @@ -180,14 +168,14 @@ class Ui.Window : Adw.ApplicationWindow { [GtkCallback] private void seek_backward () { // 10 seconds - int64 new_position = playbin.position - 10 * Gst.SECOND; - if (new_position < 0) new_position = 0; + double new_position = playbin.position - 10.0; + if (new_position < 0.0) new_position = 0.0; this.playbin.seek (new_position); } [GtkCallback] private void seek_forward () { // 10 seconds - int64 new_position = playbin.position + 10 * Gst.SECOND; + double new_position = playbin.position + 10.0; if (new_position > this.playbin.duration) new_position = this.playbin.duration; this.playbin.seek (new_position); } diff --git a/src/vapi/mpv.vapi b/src/vapi/mpv.vapi new file mode 100644 index 0000000..c7bbe8c --- /dev/null +++ b/src/vapi/mpv.vapi @@ -0,0 +1,101 @@ +[CCode (cheader_filename = "mpv/client.h")] +namespace Mpv { + + [CCode (cname = "mpv_error_string")] + public unowned string error_string (int error); + + public delegate void WakeupCallback (); + + [CCode (cname = "mpv_handle", free_function = "mpv_destroy")] + [Compact] + public class Handle { + [CCode (cname = "mpv_create")] + public Handle (); + + [CCode (cname = "mpv_initialize")] + public int initialize (); + + [CCode (cname = "mpv_wait_event")] + public unowned Event *wait_event (double timeout); + + public WakeupCallback wakeup_callback { + [CCode (cname = "mpv_set_wakeup_callback")] set; + } + + [CCode (cname = "mpv_set_property_string")] + public int set_property_string (string name, string data); + + [CCode (cname = "mpv_set_property_async")] + public int set_property_async (uint64 reply_userdata, string name, Format format, void *data); + + [CCode (cname = "mpv_command_async")] + public int command_async ( + uint64 reply_userdata, + [CCode (array_length = false)] + string[] args); + + [CCode (cname = "mpv_observe_property")] + public int observe_property (uint64 reply_userdata, string name, Format format); + } + + [CCode (cname = "mpv_format", cprefix = "MPV_FORMAT_", has_type_id = false)] + public enum Format { + NONE, + STRING, + OSD_STRING, + FLAG, + INT64, + DOUBLE, + NODE, + NODE_ARRAY, + NODE_MAP, + BYTE_ARRAY, + } + + [CCode (cname = "mpv_event_id", cprefix = "MPV_EVENT_", has_type_id = false)] + public enum EventId { + NONE, + SHUTDOWN, + LOG_MESSAGE, + GET_PROPERTY_REPLY, + SET_PROPERTY_REPLY, + COMMAND_REPLY, + START_FILE, + END_FILE, + FILE_LOADED, + CLIENT_MESSAGE, + VIDEO_RECONFIG, + AUDIO_RECONFIG, + SEEK, + PLAYBACK_RESTART, + PROPERTY_CHANGE, + QUEUE_OVERFLOW, + HOOK, + + // deprecated + IDLE, + TICK, + } + + [CCode (cname = "mpv_event")] + public struct Event { + EventId event_id; + int error; + uint64 reply_userdata; + void *data; + } + + [CCode (cname = "mpv_event_start_file")] + public struct EventStartFile { + int64 playlist_entry_id; + } + + [CCode (cname = "mpv_event_property")] + public struct EventProperty { + string name; + Format format; + void *data; + } + +} +