enum PlaybinState { STOPPED, PAUSED, PLAYING, } 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; } } public PlaybinState state { get; private set; default = PlaybinState.STOPPED; } public Subsonic.Song? current_song { get; private set; default = null; } // true if a timer should update the position property private bool update_position = false; 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; } 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); } } // 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"); // 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 (); if (next_uri != "") { playbin.uri = next_uri; } } private void on_play_queue_items_changed (ListModel play_queue, uint position, uint removed, uint added) { if (this.state == PlaybinState.STOPPED) { return; } 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 } } } public Playbin () { this.next_uri.push (""); this.playbin.source_setup.connect (this.source_setup); this.playbin.about_to_finish.connect (this.about_to_finish); // regularly update position Timeout.add (500, () => { if (this.update_position) { 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; } else { this.position = 0; } } // 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 = -1; this.update_position = true; 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); if (new_state == Gst.State.PLAYING) { if (queued_seek != -1) { if (this.playbin.seek_simple (Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE, this.queued_seek)) { this.queued_seek = -1; } else { warning ("could not reapply queued seek after state changed changed to playing, retrying later"); } } else { this.update_position = true; } } }); bus.message["eos"].connect ((message) => { this.stop (); }); } private int64 queued_seek = -1; public void seek (int64 position) { this.position = position; this.update_position = false; if (!this.playbin.seek_simple (Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE, position)) { // try to queue this seek for later queued_seek = 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 ()) { 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; } public void play () { assert (this.state != PlaybinState.STOPPED); this.playbin.set_state (Gst.State.PLAYING); this.state = PlaybinState.PLAYING; } 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; } }