public enum PlaybinState { STOPPED, PAUSED, PLAYING, } public class Playbin : GLib.Object { private Mpv.Handle mpv = new Mpv.Handle (); public PlaybinState state { get; private set; default = PlaybinState.STOPPED; } private int _volume = 100; public int volume { get { return _volume; } set { var ret = mpv.set_property_int64 ("volume", value); if (ret >= 0) { _volume = value; } else { warning ("failed to set volume: %s", ret.to_string ()); } } } public bool _mute = false; public bool mute { get { return _mute; } set { var ret = mpv.set_property_flag ("mute", value); if (ret >= 0) { _mute = value; } else { warning ("failed to set mute status: %s", ret.to_string ()); } } } // invariant: equal to play queue length iff state is STOPPED public uint play_queue_position { get; private set; } // signalled when a new track is current public signal void new_track (); // signalled when the last track is over public signal void stopped (); // these are mostly synced with mpv 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; } public ListStore _play_queue; public ListModel play_queue { get { return this._play_queue; } } // try to prevent wait_event to be called twice private bool is_handling_event = false; public Playbin () { this._play_queue = new ListStore (typeof (Subsonic.Song)); assert (this.mpv.initialize () >= 0); assert (this.mpv.set_property_string ("user-agent", Audrey.Const.user_agent) >= 0); assert (this.mpv.set_property_string ("video", "no") >= 0); assert (this.mpv.set_property_string ("prefetch-playlist", "yes") >= 0); assert (this.mpv.set_property_string ("gapless-audio", "yes") >= 0); assert (this.mpv.observe_property (0, "time-pos", Mpv.Format.DOUBLE) >= 0); assert (this.mpv.observe_property (1, "duration", Mpv.Format.DOUBLE) >= 0); assert (this.mpv.observe_property (2, "playlist-pos", Mpv.Format.INT64) >= 0); assert (this.mpv.observe_property (3, "pause", Mpv.Format.FLAG) >= 0); this.mpv.wakeup_callback = () => { Idle.add (() => { if (this.is_handling_event) return false; this.is_handling_event = true; while (true) { var event = this.mpv.wait_event (0.0); if (event.event_id == Mpv.EventId.NONE) break; switch (event.event_id) { case Mpv.EventId.PROPERTY_CHANGE: var data = event.parse_property (); switch (event.reply_userdata) { case 0: assert (data.name == "time-pos"); if (data.format == Mpv.Format.NONE) { this.position = 0.0; } else { this.position = data.parse_double (); } break; case 1: assert (data.name == "duration"); if (data.format == Mpv.Format.NONE) { this.duration = 0.0; } else { this.duration = data.parse_double (); } break; case 2: // here as a sanity check // should always match our own play_queu_position/state assert (data.name == "playlist-pos"); int64 playlist_pos = data.parse_int64 (); if (playlist_pos < 0) { if (this.state != PlaybinState.STOPPED) { error ("mpv has no current playlist entry, but we think it's index %u", this.play_queue_position); } assert (this.play_queue_position == this.play_queue.get_n_items ()); } else { if (this.state == PlaybinState.STOPPED) { error ("mpv is at playlist entry %u, but we're stopped", (uint) playlist_pos); } if (this.play_queue_position != (uint) playlist_pos) { error ("mpv is at playlist entry %u, but we think it's %u", (uint) playlist_pos, this.play_queue_position); } } break; case 3: // also here as a sanity check // should always match our own state assert (data.name == "pause"); bool pause = data.parse_flag (); if (pause && this.state != PlaybinState.PAUSED) { error (@"mpv is paused, but we are @(this.state)"); } if (!pause && this.state == PlaybinState.PAUSED) { error ("mpv is not paused, but we are paused"); } break; default: assert (false); break; } break; case Mpv.EventId.START_FILE: debug ("START_FILE received"); // estimate duration from api data // while mpv doesn't know it this.duration = ((Subsonic.Song) this._play_queue.get_item (this.play_queue_position)).duration; this.new_track (); break; case Mpv.EventId.END_FILE: var data = event.parse_end_file (); debug (@"END_FILE received (reason: $(data.reason))"); if (data.error < 0) { warning ("playback of track aborted: %s", data.error.to_string ()); } if (data.reason == Mpv.EndFileReason.EOF) { // assume this is a proper transition this.play_queue_position += 1; if (this.play_queue_position == this._play_queue.get_n_items ()) { // reached the end (?) this.state = PlaybinState.STOPPED; this.stopped (); } } break; default: // ignore by default break; } } this.is_handling_event = false; return false; }); }; } public void seek (double position) { var rc = this.mpv.command ({"seek", position.to_string (), "absolute"}); if (rc < 0) { warning (@"could not seek to $position: $rc"); } else { this.position = position; } } // manually changes which track in the play queue to play public void select_track (uint position) requires (position < this.play_queue.get_n_items ()) { assert (this.mpv.command ({"playlist-play-index", position.to_string ()}) >= 0); this.state = PlaybinState.PLAYING; this.play_queue_position = position; } public void pause () { assert (this.state != PlaybinState.STOPPED); this.state = PlaybinState.PAUSED; debug ("setting state to paused"); // TODO: abstract away this handling around mpv api a bit for auto debug printing var ret = this.mpv.set_property_flag("pause", true); if (ret != 0) { debug (@"failed to set state to paused ($(ret)): $(ret.to_string())"); } } public void play () { assert (this.state != PlaybinState.STOPPED); this.state = PlaybinState.PLAYING; debug ("setting state to playing"); var ret = this.mpv.set_property_flag("pause", false); if (ret != 0) { debug (@"failed to set state to playing ($(ret)): $(ret.to_string())"); } } public void go_to_next_track () requires (this.state != PlaybinState.STOPPED) { if (this.play_queue_position+1 < this._play_queue.get_n_items ()) { this.play_queue_position += 1; assert (this.mpv.command ({"playlist-next-playlist"}) >= 0); } else { warning ("tried to skip forward at end of play queue, ignoring"); } } public void go_to_prev_track () requires (this.state != PlaybinState.STOPPED) { if (this.play_queue_position > 0) { this.play_queue_position -= 1; assert (this.mpv.command ({"playlist-prev-playlist"}) >= 0); } else { warning ("tried to skip to prev track at start of play queue, ignoring"); } } public void remove_track (uint position) requires (position < this._play_queue.get_n_items ()) { assert (this.mpv.command({"playlist-remove", position.to_string ()}) >= 0); this._play_queue.remove (position); if (this.play_queue_position > position) this.play_queue_position -= 1; if (this.play_queue_position == this._play_queue.get_n_items ()) { // we just killed the last track this.state = PlaybinState.STOPPED; this.stopped (); } } public void clear () { assert (this.mpv.command({"playlist-clear"}) >= 0); if (this.state != PlaybinState.STOPPED) { assert (this.mpv.command({"playlist-remove", "current"}) >= 0); } this.state = PlaybinState.STOPPED; this._play_queue.remove_all (); this.play_queue_position = 0; this.stopped (); } public void append_track (Subsonic.Song song) { assert (this.mpv.command({ "loadfile", this.api.stream_uri (song.id), "append", }) >= 0); if (this.state == STOPPED) this.play_queue_position += 1; this._play_queue.append (song); } }