public enum PlaybinState { STOPPED, PAUSED, PLAYING, } private struct CommandCallback { unowned SourceFunc callback; int error; } 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 weak Subsonic.Client api { get; set; default = null; } public ListStore _play_queue; public ListModel play_queue { get { return this._play_queue; } } private async Mpv.Error mpv_command_async (string[] args) { CommandCallback cc = {}; this.mpv.command_async ((uint64) &cc, args); cc.callback = this.mpv_command_async.callback; yield; return cc.error; } // should be Mpv.WakeupCallback, but i think there's a vala bug here private SourceOnceFunc wakeup_callback; // anchor reference here, mpv won't remind us 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); int wakeup_fds[2]; try { assert (Unix.open_pipe (wakeup_fds, 0)); } catch (Error e) { error (@"could not open pipe for mpv wakeup: $(e.message)"); } IOChannel wakeup_read = new IOChannel.unix_new (wakeup_fds[0]); IOChannel wakeup_write = new IOChannel.unix_new (wakeup_fds[1]); wakeup_read.set_close_on_unref (true); wakeup_write.set_close_on_unref (true); try { wakeup_read.set_encoding (null); wakeup_write.set_encoding (null); wakeup_write.set_buffered (false); } catch (Error e) { error (@"could not set up pipes for mpv wakeup: $(e.message)"); } this.wakeup_callback = () => { try { wakeup_write.write_chars ({0}, null); } catch (Error e) { error (@"could not write to mpv wakeup pipe: $(e.message)"); } }; this.mpv.wakeup_callback = this.wakeup_callback; assert (0 < wakeup_read.add_watch (IOCondition.IN, (source, condition) => { try { wakeup_read.read_chars ({0}, null); } catch (Error e) { error (@"could not read from mpv wakeup pipe: $(e.message)"); } 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; i think this prevents the fallback below from working } 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; case Mpv.EventId.COMMAND_REPLY: unowned CommandCallback *cc = (CommandCallback *) event.reply_userdata; cc.error = event.error; cc.callback (); break; default: // ignore by default break; } } return true; })); } 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); } public async void append_track_async (Subsonic.Song song) { var err = yield this.mpv_command_async ({ "loadfile", this.api.stream_uri (song.id), "append", }); assert (err >= 0); if (this.state == STOPPED) this.play_queue_position += 1; this._play_queue.append (song); } public void move_track (uint from, uint to) requires (from < this._play_queue.get_n_items ()) requires (to < this._play_queue.get_n_items ()) { debug (@"moving track $from to $to"); if (from < to) { // why offset to? because if the playlist is 01234, // mpv takes "move 1 to 3" to mean 02134, not 02314 // that is, the target is a "gap", not a playlist entry // from -> 0 1 2 3 4 5 // to -> 0 1 2 3 4 5 6 assert(this.mpv.command({ "playlist-move", from.to_string (), (to+1).to_string (), }) >= 0); // F0123T -> 0123TF var additions = new Object[to-from+1]; for (uint i = from+1; i < to; i += 1) { additions[i-from-1] = this._play_queue.get_item (i); } additions[to-from-1] = this._play_queue.get_item (to); additions[to-from] = this._play_queue.get_item (from); this._play_queue.splice(from, to-from+1, additions); if (this.play_queue_position == from) this.play_queue_position = to; else if (this.play_queue_position > from && this.play_queue_position <= to) this.play_queue_position -= 1; } else if (from > to) { assert(this.mpv.command({ "playlist-move", from.to_string (), to.to_string (), }) >= 0); // T0123F -> FT0123 var additions = new Object[from-to+1]; additions[0] = this._play_queue.get_item (from); for (uint i = to; i < from; i += 1) { additions[i-to+1] = this._play_queue.get_item (i); } this._play_queue.splice (to, from-to+1, additions); if (this.play_queue_position == from) this.play_queue_position = to; else if (this.play_queue_position >= to && this.play_queue_position < from) this.play_queue_position += 1; } } ~Playbin () { debug ("destroying playbin"); } }