audrey/src/playbin.vala
2024-10-29 15:46:33 +01:00

438 lines
17 KiB
Vala

public enum Audrey.PlaybinState {
STOPPED,
PAUSED,
PLAYING,
}
private struct Audrey.CommandCallback {
unowned SourceFunc callback;
int error;
}
public class Audrey.PlaybinSong : Object {
private Subsonic.Song inner;
public string id { get { return inner.id; } }
public string title { get { return inner.title; } }
public string artist { get { return inner.artist; } }
public string album { get { return inner.album; } }
public string? genre { get { return inner.genre; } }
public int64 duration { get { return inner.duration; } }
public int64 track { get { return inner.track; } }
public int64 play_count { get { return inner.play_count; } }
public Gdk.Paintable? thumbnail { get; private set; }
private Cancellable cancel_loading_thumbnail;
public PlaybinSong (Subsonic.Client api, Subsonic.Song song) {
this.api = api;
this.inner = song;
}
private Subsonic.Client api;
public void need_cover_art () {
/* TODO
if (this.cancel_loading_thumbnail != null) return;
if (this.thumbnail != null) return;
this.cancel_loading_thumbnail = new Cancellable ();
// TODO: dpi scaling maybe?? probably
api.cover_art.begin (this.id, 50, Priority.LOW, this.cancel_loading_thumbnail, (obj, res) => {
try {
var pixbuf = api.cover_art.end (res);
this.thumbnail = Gdk.Texture.for_pixbuf (pixbuf);
} catch (Error e) {
if (!(e is IOError.CANCELLED)) {
warning ("could not fetch cover art for song %s: %s", this.id, e.message);
}
}
this.cancel_loading_thumbnail = null;
});
*/
}
~PlaybinSong () {
if (this.cancel_loading_thumbnail != null) {
this.cancel_loading_thumbnail.cancel ();
}
}
}
public class Audrey.Playbin : GLib.Object {
private Mpv.Handle mpv = new Mpv.Handle ();
private int _volume = 100;
private bool _mute = false;
private ListStore _play_queue = new ListStore (typeof (PlaybinSong));
// try to prevent wait_event to be called twice
private bool is_handling_event = false;
public PlaybinState state { get; private set; default = PlaybinState.STOPPED; }
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 {
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: negative iff stopped, otherwise < play queue length
public int play_queue_position { get; private set; default = -1; }
// 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 ListModel play_queue { get { return this._play_queue; } }
public uint play_queue_length { get; private set; default = 0; }
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;
}
public Playbin () {
assert (this.mpv.initialize () >= 0);
assert (this.mpv.set_property_string ("audio-client-name", "audrey") >= 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) {
warning ("main thread mpv wakeup callback called twice");
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; 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 %d", this.play_queue_position);
}
assert (this.play_queue_position < 0);
} else {
if (this.state == PlaybinState.STOPPED) {
error ("mpv is at playlist entry %d, but we're stopped", (int) playlist_pos);
}
if (this.play_queue_position != (int) playlist_pos) {
error ("mpv is at playlist entry %d, but we think it's %d", (int) 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 = ((PlaybinSong) 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.play_queue_position = -1;
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;
}
}
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.play_queue_position = (int) position;
this.state = PlaybinState.PLAYING;
this.play (); // make sure mpv actually starts playing the track
}
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 () {
if (this.state == PlaybinState.STOPPED) {
// allow only when playlist is not empty
// and start from the top
assert (this._play_queue.get_n_items () > 0);
this.select_track (0);
} else {
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);
this.play_queue_length -= 1;
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.play_queue_position = -1;
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_length = 0;
this.play_queue_position = -1;
this.stopped ();
}
public void append_track (Subsonic.Song song) {
assert (this.mpv.command ({
"loadfile",
this.api.stream_uri (song.id),
"append",
}) >= 0);
this._play_queue.append (new PlaybinSong (this.api, song));
this.play_queue_length += 1;
}
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);
this._play_queue.append (new PlaybinSong (this.api, song));
this.play_queue_length += 1;
}
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 == (int) from) this.play_queue_position = (int) to;
else if (this.play_queue_position > (int) from && this.play_queue_position <= (int) 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 == (int) from) this.play_queue_position = (int) to;
else if (this.play_queue_position >= (int) to && this.play_queue_position < (int) from) this.play_queue_position += 1;
}
}
public void stop () {
debug ("stopping playback");
// don't clear the playlist, just in case (less state changes to sync)
assert(this.mpv.command({"stop", "keep-playlist"}) >= 0);
this.state = PlaybinState.STOPPED;
this.play_queue_position = -1;
this.stopped ();
}
~Playbin () {
debug ("destroying playbin");
}
}