/** * Object which tracks players through their mpris dbus interface. * The most simple way is to use [class@AstalMpris.Mpris] which tracks every player, * but [class@AstalMpris.Player] can be constructed for a dedicated players too. */ public class AstalMpris.Player : Object { private static string COVER_CACHE = Environment.get_user_cache_dir() + "/astal/mpris"; private IPlayer proxy; private uint pollid; // periodically notify position internal signal void appeared() { available = true; } internal signal void closed() { available = false; } /** * Full dbus namae of this player. */ public string bus_name { owned get; private set; } /** * Indicates if [property@AstalMpris.Player:bus_name] is available on dbus. */ public bool available { get; private set; } // mpris /** * Brings the player's user interface to the front * using any appropriate mechanism available. * * The media player may be unable to control how its user interface is displayed, * or it may not have a graphical user interface at all. * In this case, the [property@AstalMpris.Player:can_raise] is `false` and this method does nothing. */ public void raise() { try { proxy.raise(); } catch (Error err) { critical(err.message); } } /** * Causes the media player to stop running. * * The media player may refuse to allow clients to shut it down. * In this case, the [property@AstalMpris.Player:can_quit] property is false and this method does nothing. */ public void quit() { try { proxy.quit(); } catch (Error err) { critical(err.message); } } /** * Indicates if [method@AstalMpris.Player.quit] has any effect. */ public bool can_quit { get; private set; } /** * Indicates if the player is occupying the fullscreen. This is typically used for videos. * Use [method@AstalMpris.Player.toggle_fullscreen] to toggle fullscreen state. */ public bool fullscreen { get; private set; } /** * Indicates if [method@AstalMpris.Player.toggle_fullscreen] has any effect. */ public bool can_set_fullscreen { get; private set; } /** * Indicates if [method@AstalMpris.Player.raise] has any effect. */ public bool can_raise { get; private set; } // TODO: Tracklist interface // public bool has_track_list { get; private set; } /** * A human friendly name to identify the player. */ public string identity { owned get; private set; } /** * The base name of a .desktop file */ public string entry { owned get; private set; } /** * The URI schemes supported by the media player. * * This can be viewed as protocols supported by the player in almost all cases. * Almost every media player will include support for the "file" scheme. * Other common schemes are "http" and "rtsp". */ public string[] supported_uri_schemes { owned get; private set; } /** * The mime-types supported by the player. */ public string[] supported_mime_types { owned get; private set; } /** * Toggle [property@AstalMpris.Player:fullscreen] state. */ public void toggle_fullscreen() { if (!can_set_fullscreen) critical(@"can not set fullscreen on $bus_name"); proxy.fullscreen = !fullscreen; } /** * Skips to the next track in the tracklist. * If there is no next track (and endless playback and track repeat are both off), stop playback. * If [property@AstalMpris.Player:can_go_next] is `false` this method has no effect. */ public void next() { try { proxy.next(); } catch (Error error) { critical(error.message); } } /** * Skips to the previous track in the tracklist. * If there is no previous track (and endless playback and track repeat are both off), stop playback. * If [property@AstalMpris.Player:can_go_previous] is `false` this method has no effect. */ public void previous() { try { proxy.previous(); } catch (Error error) { critical(error.message); } } /** * Pauses playback. * If playback is already paused, this has no effect. * If [property@AstalMpris.Player:can_pause] is `false` this method has no effect. */ public void pause() { try { proxy.pause(); } catch (Error error) { critical(error.message); } } /** * Pauses playback. * If playback is already paused, resumes playback. * If playback is stopped, starts playback. */ public void play_pause() { try { proxy.play_pause(); } catch (Error error) { critical(error.message); } } /** * Stops playback. * If playback is already stopped, this has no effect. * If [property@AstalMpris.Player:can_control] is `false` this method has no effect. */ public void stop() { try { proxy.stop(); } catch (Error error) { critical(error.message); } } /** * Starts or resumes playback. * If already playing, this has no effect. * If paused, playback resumes from the current position. * If [property@AstalMpris.Player:can_play] is `false` this method has no effect. */ public void play() { try { proxy.play(); } catch (Error error) { critical(error.message); } } /** * uri scheme should be an element of [property@AstalMpris.Player:supported_uri_schemes] * and the mime-type should match one of the elements of [property@AstalMpris.Player:supported_mime_types]. * * @param uri Uri of the track to load. */ public void open_uri(string uri) { try { proxy.open_uri(uri); } catch (Error error) { critical(error.message); } } /** * Change [property@AstalMpris.Player:loop_status] from none to track, * from track to playlist, from playlist to none. */ public void loop() { if (loop_status == Loop.UNSUPPORTED) { critical(@"loop is unsupported by $bus_name"); return; } switch (loop_status) { case Loop.NONE: loop_status = Loop.TRACK; break; case Loop.TRACK: loop_status = Loop.PLAYLIST; break; case Loop.PLAYLIST: loop_status = Loop.NONE; break; default: break; } } /** * Toggle [property@AstalMpris.Player:shuffle_status]. */ public void shuffle() { if (shuffle_status == Shuffle.UNSUPPORTED) { critical(@"shuffle is unsupported by $bus_name"); return; } shuffle_status = shuffle_status == Shuffle.ON ? Shuffle.OFF : Shuffle.ON; } private double _get_position() { try { var reply = proxy.call_sync( "org.freedesktop.DBus.Properties.Get", new Variant("(ss)", "org.mpris.MediaPlayer2.Player", "Position" ), DBusCallFlags.NONE, -1, null ); var body = reply.get_child_value(0); if (body.classify() == Variant.Class.STRING) { return -1; // Position not supported } return (double)body.get_variant().get_int64() / 1000000; } catch (Error err) { return -1; } } private void _set_position(double pos) { try { proxy.set_position((ObjectPath)trackid, (int64)(pos * 1000000)); } catch (Error error) { critical(error.message); } } private Loop _loop_status = Loop.UNSUPPORTED; private double _rate; private Shuffle _shuffle_status = Shuffle.UNSUPPORTED; private double _volume = -1; /** * The current loop/repeat status. */ public Loop loop_status { get { return _loop_status; } set { proxy.loop_status = value.to_string(); } } /** * The current playback rate. */ public double rate { get { return _rate; } set { proxy.rate = value; } } /** * The current shuffle status. */ public Shuffle shuffle_status { get { return _shuffle_status; } set { proxy.shuffle = value == Shuffle.ON; } } /** * The current volume level between 0 and 1. */ public double volume { get { return _volume; } set { proxy.volume = value; } } /** * The current position of the track in seconds. * To get a progress percentage simply divide this with [property@AstalMpris.Player:length]. */ public double position { get { return _get_position(); } set { _set_position(value); } } /** * The current playback status. */ public PlaybackStatus playback_status { get; private set; } /** * The minimum value which the [property@AstalMpris.Player:rate] can take. */ public double minimum_rate { get; private set; } /** * The maximum value which the [property@AstalMpris.Player:rate] can take. */ public double maximum_rate { get; private set; } /** * Indicates if invoking [method@AstalMpris.Player.next] has effect. */ public bool can_go_next { get; private set; } /** * Indicates if invoking [method@AstalMpris.Player.previous] has effect. */ public bool can_go_previous { get; private set; } /** * Indicates if invoking [method@AstalMpris.Player.play] has effect. */ public bool can_play { get; private set; } /** * Indicates if invoking [method@AstalMpris.Player.pause] has effect. */ public bool can_pause { get; private set; } /** * Indicates if setting [property@AstalMpris.Player:position] has effect. */ public bool can_seek { get; private set; } /** * Indicates if the player can be controlled with * methods such as [method@AstalMpris.Player.play_pause]. */ public bool can_control { get; private set; } /** * Metadata hashtable of this player. * In languages that cannot introspect this * use [method@AstalMpris.Player.get_meta]. */ [CCode (notify = false)] // notified manually in sync public HashTable metadata { owned get; private set; } /** * Currently playing track's id. */ public string trackid { owned get; private set; } /** * Length of the currently playing track in seconds. */ public double length { get; private set; } /** * The location of an image representing the track or album. * You should always prefer to use [property@AstalMpris.Player:cover_art]. */ public string art_url { owned get; private set; } /** * Title of the currently playing album. */ public string album { owned get; private set; } /** * Artists of the currently playing album. */ public string album_artist { owned get; private set; } /** * Artists of the currently playing track. */ public string artist { owned get; private set; } /** * Lyrics of the currently playing track. */ public string lyrics { owned get; private set; } /** * Title of the currently playing track. */ public string title { owned get; private set; } /** * Composers of the currently playing track. */ public string composer { owned get; private set; } /** * Comments of the currently playing track. */ public string comments { owned get; private set; } /** * Path of the cached [property@AstalMpris.Player:art_url]. */ public string cover_art { owned get; private set; } /** * Lookup a key from [property@AstalMpris.Player:metadata]. * This method is useful for languages that fail to introspect hashtables. */ public Variant? get_meta(string key) { return metadata.lookup(key); } /** * Construct a Player that tracks a dbus name. For example "org.mpris.MediaPlayer2.spotify". * The "org.mpris.MediaPlayer2." prefix can be leftout so simply "spotify" would mean the same. * [property@AstalMpris.Player:available] indicates whether the player is actually running or not. * * @param name dbus name of the player. */ public Player(string name) { bus_name = name.has_prefix("org.mpris.MediaPlayer2.") ? name : @"org.mpris.MediaPlayer2.$name"; } private void sync() { // mpris can_quit = proxy.can_quit; fullscreen = proxy.fullscreen; can_set_fullscreen = proxy.can_set_fullscreen; can_raise = proxy.can_raise; // has_track_list = proxy.has_track_list; identity = proxy.identity; entry = proxy.desktop_entry; supported_uri_schemes = proxy.supported_uri_schemes; supported_mime_types = proxy.supported_mime_types; if (position >= 0) notify_property("position"); // LoopStatus and Shuffle are optional props var props = proxy.get_all("org.mpris.MediaPlayer2.Player"); // player if (props != null && props.get("LoopStatus") != null) { if (loop_status != Loop.from_string(proxy.loop_status)) { _loop_status = Loop.from_string(proxy.loop_status); notify_property("loop-status"); } } else if (loop_status != Loop.UNSUPPORTED) { _loop_status = Loop.UNSUPPORTED; notify_property("loop-status"); } if (rate != proxy.rate) { _rate = proxy.rate; notify_property("rate"); } if (props != null && props.get("Shuffle") != null) { if (shuffle_status != Shuffle.from_bool(proxy.shuffle)) { _shuffle_status = Shuffle.from_bool(proxy.shuffle); notify_property("shuffle-status"); } } else if (shuffle_status != Shuffle.UNSUPPORTED) { _shuffle_status = Shuffle.UNSUPPORTED; notify_property("shuffle-status"); } if (volume != proxy.volume) { _volume = proxy.volume; notify_property("volume"); } playback_status = PlaybackStatus.from_string(proxy.playback_status); minimum_rate = proxy.minimum_rate; maximum_rate = proxy.maximum_rate; can_go_next = proxy.can_go_next; can_go_previous = proxy.can_go_previous; can_play = proxy.can_play; can_pause = proxy.can_pause; can_seek = proxy.can_seek; can_control = proxy.can_control; // metadata metadata = proxy.metadata; if (metadata != null) { if (metadata.get("mpris:length") != null) { var v = metadata.get("mpris:length"); if (v.get_type_string() == "x") { length = (double)v.get_int64() / 1000000; } else if (v.get_type_string() == "t") { length = (double)v.get_uint64() / 1000000; } } else { length = -1; } trackid = get_str("mpris:trackid"); art_url = get_str("mpris:artUrl"); album = get_str("xesam:album"); lyrics = get_str("xesam:asText"); title = get_str("xesam:title"); album_artist = join_strv("xesam:albumArtist", ", "); artist = join_strv("xesam:artist", ", "); comments = join_strv("xesam:comments", "\n"); composer = join_strv("xesam:composer", ", "); cache_cover.begin((_, res) => cache_cover.end(res)); notify_property("metadata"); } } private async void cache_cover() { if (art_url == null || art_url == "") { cover_art = null; return; } var file = File.new_for_uri(art_url); if (file.get_path() != null) { cover_art = file.get_path(); return; } var path = COVER_CACHE + "/" + Checksum.compute_for_string(ChecksumType.SHA1, art_url, -1); if (FileUtils.test(path, FileTest.EXISTS)) { cover_art = path; return; } try { if (!FileUtils.test(COVER_CACHE, FileTest.IS_DIR)) File.new_for_path(COVER_CACHE).make_directory_with_parents(null); file.copy_async.begin( File.new_for_path(path), FileCopyFlags.OVERWRITE, Priority.DEFAULT, null, null, (_, res) => { try { file.copy_async.end(res); cover_art = path; } catch (Error err) { critical("Failed to cache cover art with url \"%s\": %s", art_url, err.message); } } ); } catch (Error err) { critical(err.message); } } private string get_str(string key) { if (metadata.get(key) == null) return ""; var str = metadata.get(key).get_string(null); return str == null ? "" : str; } private string? join_strv(string key, string sep) { if (metadata.get(key) == null) return null; var arr = metadata.get(key).get_strv(); if (arr.length == 0) return null; var builder = new StringBuilder(); for (var i = 0; i < arr.length; ++i) { builder.append(arr[i]); if (i + 1 < arr.length) builder.append(sep); } return builder.str; } construct { notify["bus-name"].connect(() => { try { setup_proxy(); setup_position_poll(); sync(); } catch (Error error) { critical(error.message); } }); } private void setup_position_poll() { var current_position = position; pollid = Timeout.add_seconds(1, () => { if (!available) return Source.CONTINUE; if (position >= 0 && current_position != position) { current_position = position; notify_property("position"); } return Source.CONTINUE; }, Priority.DEFAULT); } private void setup_proxy() throws Error { if (proxy != null) return; proxy = Bus.get_proxy_sync( BusType.SESSION, bus_name, "/org/mpris/MediaPlayer2" ); if (proxy.g_name_owner != null) { appeared(); } proxy.notify["g-name-owner"].connect(() => { if (proxy.g_name_owner != null) { appeared(); } else { closed(); } }); proxy.g_properties_changed.connect(sync); } ~Player() { Source.remove(pollid); } } public enum AstalMpris.PlaybackStatus { PLAYING, PAUSED, STOPPED; internal static PlaybackStatus from_string(string? str) { switch (str) { case "Playing": return PLAYING; case "Paused": return PAUSED; case "Stopped": default: return STOPPED; } } } public enum AstalMpris.Loop { UNSUPPORTED, /** The playback will stop when there are no more tracks to play. */ NONE, /** The current track will start again from the begining once it has finished playing. */ TRACK, /** The playback loops through a list of tracks. */ PLAYLIST; internal static Loop from_string(string? str) { switch (str) { case "None": return NONE; case "Track": return TRACK; case "Playlist": return PLAYLIST; default: return UNSUPPORTED; } } internal string? to_string() { switch (this) { case NONE: return "None"; case TRACK: return "Track"; case PLAYLIST: return "Playlist"; default: return "Unsupported"; } } } public enum AstalMpris.Shuffle { UNSUPPORTED, /** Playback is progressing through a playlist in some other order. */ ON, /** Playback is progressing linearly through a playlist. */ OFF; internal static Shuffle from_bool(bool b) { return b ? Shuffle.ON : Shuffle.OFF; } internal string? to_string() { switch (this) { case OFF: return "Off"; case ON: return "On"; default: return "Unsupported"; } } }