// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect // // SPDX-License-Identifier: GPL-2.0-or-later import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; export const Player = GObject.registerClass({ GTypeName: 'GSConnectMediaPlayerInterface', Properties: { // Application Properties 'CanQuit': GObject.ParamSpec.boolean( 'CanQuit', 'Can Quit', 'Whether the client can call the Quit method.', GObject.ParamFlags.READABLE, false ), 'Fullscreen': GObject.ParamSpec.boolean( 'Fullscreen', 'Fullscreen', 'Whether the player is in fullscreen mode.', GObject.ParamFlags.READWRITE, false ), 'CanSetFullscreen': GObject.ParamSpec.boolean( 'CanSetFullscreen', 'Can Set Fullscreen', 'Whether the client can set the Fullscreen property.', GObject.ParamFlags.READABLE, false ), 'CanRaise': GObject.ParamSpec.boolean( 'CanRaise', 'Can Raise', 'Whether the client can call the Raise method.', GObject.ParamFlags.READABLE, false ), 'HasTrackList': GObject.ParamSpec.boolean( 'HasTrackList', 'Has Track List', 'Whether the player has a track list.', GObject.ParamFlags.READABLE, false ), 'Identity': GObject.ParamSpec.string( 'Identity', 'Identity', 'The application name.', GObject.ParamFlags.READABLE, null ), 'DesktopEntry': GObject.ParamSpec.string( 'DesktopEntry', 'DesktopEntry', 'The basename of an installed .desktop file.', GObject.ParamFlags.READABLE, null ), 'SupportedUriSchemes': GObject.param_spec_variant( 'SupportedUriSchemes', 'Supported URI Schemes', 'The URI schemes supported by the media player.', new GLib.VariantType('as'), null, GObject.ParamFlags.READABLE ), 'SupportedMimeTypes': GObject.param_spec_variant( 'SupportedMimeTypes', 'Supported MIME Types', 'The mime-types supported by the media player.', new GLib.VariantType('as'), null, GObject.ParamFlags.READABLE ), // Player Properties 'PlaybackStatus': GObject.ParamSpec.string( 'PlaybackStatus', 'Playback Status', 'The current playback status.', GObject.ParamFlags.READABLE, null ), 'LoopStatus': GObject.ParamSpec.string( 'LoopStatus', 'Loop Status', 'The current loop status.', GObject.ParamFlags.READWRITE, null ), 'Rate': GObject.ParamSpec.double( 'Rate', 'Rate', 'The current playback rate.', GObject.ParamFlags.READWRITE, 0.0, 1.0, 1.0 ), 'MinimumRate': GObject.ParamSpec.double( 'MinimumRate', 'Minimum Rate', 'The minimum playback rate.', GObject.ParamFlags.READWRITE, 0.0, 1.0, 1.0 ), 'MaximimRate': GObject.ParamSpec.double( 'MaximumRate', 'Maximum Rate', 'The maximum playback rate.', GObject.ParamFlags.READWRITE, 0.0, 1.0, 1.0 ), 'Shuffle': GObject.ParamSpec.boolean( 'Shuffle', 'Shuffle', 'Whether track changes are linear.', GObject.ParamFlags.READWRITE, null ), 'Metadata': GObject.param_spec_variant( 'Metadata', 'Metadata', 'The metadata of the current element.', new GLib.VariantType('a{sv}'), null, GObject.ParamFlags.READABLE ), 'Volume': GObject.ParamSpec.double( 'Volume', 'Volume', 'The volume level.', GObject.ParamFlags.READWRITE, 0.0, 1.0, 1.0 ), 'Position': GObject.ParamSpec.int64( 'Position', 'Position', 'The current track position in microseconds.', GObject.ParamFlags.READABLE, 0, Number.MAX_SAFE_INTEGER, 0 ), 'CanGoNext': GObject.ParamSpec.boolean( 'CanGoNext', 'Can Go Next', 'Whether the client can call the Next method.', GObject.ParamFlags.READABLE, false ), 'CanGoPrevious': GObject.ParamSpec.boolean( 'CanGoPrevious', 'Can Go Previous', 'Whether the client can call the Previous method.', GObject.ParamFlags.READABLE, false ), 'CanPlay': GObject.ParamSpec.boolean( 'CanPlay', 'Can Play', 'Whether playback can be started using Play or PlayPause.', GObject.ParamFlags.READABLE, false ), 'CanPause': GObject.ParamSpec.boolean( 'CanPause', 'Can Pause', 'Whether playback can be paused using Play or PlayPause.', GObject.ParamFlags.READABLE, false ), 'CanSeek': GObject.ParamSpec.boolean( 'CanSeek', 'Can Seek', 'Whether the client can control the playback position using Seek and SetPosition.', GObject.ParamFlags.READABLE, false ), 'CanControl': GObject.ParamSpec.boolean( 'CanControl', 'Can Control', 'Whether the media player may be controlled over this interface.', GObject.ParamFlags.READABLE, false ), }, Signals: { 'Seeked': { flags: GObject.SignalFlags.RUN_FIRST, param_types: [GObject.TYPE_INT64], }, }, }, class Player extends GObject.Object { /* * The org.mpris.MediaPlayer2 Interface */ get CanQuit() { if (this._CanQuit === undefined) this._CanQuit = false; return this._CanQuit; } get CanRaise() { if (this._CanRaise === undefined) this._CanRaise = false; return this._CanRaise; } get CanSetFullscreen() { if (this._CanFullscreen === undefined) this._CanFullscreen = false; return this._CanFullscreen; } get DesktopEntry() { if (this._DesktopEntry === undefined) return 'org.gnome.Shell.Extensions.GSConnect'; return this._DesktopEntry; } get Fullscreen() { if (this._Fullscreen === undefined) this._Fullscreen = false; return this._Fullscreen; } set Fullscreen(mode) { if (this.Fullscreen === mode) return; this._Fullscreen = mode; this.notify('Fullscreen'); } get HasTrackList() { if (this._HasTrackList === undefined) this._HasTrackList = false; return this._HasTrackList; } get Identity() { if (this._Identity === undefined) this._Identity = ''; return this._Identity; } get SupportedMimeTypes() { if (this._SupportedMimeTypes === undefined) this._SupportedMimeTypes = []; return this._SupportedMimeTypes; } get SupportedUriSchemes() { if (this._SupportedUriSchemes === undefined) this._SupportedUriSchemes = []; return this._SupportedUriSchemes; } Quit() { throw new GObject.NotImplementedError(); } Raise() { throw new GObject.NotImplementedError(); } /* * The org.mpris.MediaPlayer2.Player Interface */ get CanControl() { if (this._CanControl === undefined) this._CanControl = false; return this._CanControl; } get CanGoNext() { if (this._CanGoNext === undefined) this._CanGoNext = false; return this._CanGoNext; } get CanGoPrevious() { if (this._CanGoPrevious === undefined) this._CanGoPrevious = false; return this._CanGoPrevious; } get CanPause() { if (this._CanPause === undefined) this._CanPause = false; return this._CanPause; } get CanPlay() { if (this._CanPlay === undefined) this._CanPlay = false; return this._CanPlay; } get CanSeek() { if (this._CanSeek === undefined) this._CanSeek = false; return this._CanSeek; } get LoopStatus() { if (this._LoopStatus === undefined) this._LoopStatus = 'None'; return this._LoopStatus; } set LoopStatus(status) { if (this.LoopStatus === status) return; this._LoopStatus = status; this.notify('LoopStatus'); } get MaximumRate() { if (this._MaximumRate === undefined) this._MaximumRate = 1.0; return this._MaximumRate; } get Metadata() { if (this._Metadata === undefined) { this._Metadata = { 'xesam:artist': [_('Unknown')], 'xesam:album': _('Unknown'), 'xesam:title': _('Unknown'), 'mpris:length': 0, }; } return this._Metadata; } get MinimumRate() { if (this._MinimumRate === undefined) this._MinimumRate = 1.0; return this._MinimumRate; } get PlaybackStatus() { if (this._PlaybackStatus === undefined) this._PlaybackStatus = 'Stopped'; return this._PlaybackStatus; } get Position() { if (this._Position === undefined) this._Position = 0; return this._Position; } get Rate() { if (this._Rate === undefined) this._Rate = 1.0; return this._Rate; } set Rate(rate) { if (this.Rate === rate) return; this._Rate = rate; this.notify('Rate'); } get Shuffle() { if (this._Shuffle === undefined) this._Shuffle = false; return this._Shuffle; } set Shuffle(mode) { if (this.Shuffle === mode) return; this._Shuffle = mode; this.notify('Shuffle'); } get Volume() { if (this._Volume === undefined) this._Volume = 1.0; return this._Volume; } set Volume(level) { if (this.Volume === level) return; this._Volume = level; this.notify('Volume'); } Next() { throw new GObject.NotImplementedError(); } OpenUri(uri) { throw new GObject.NotImplementedError(); } Previous() { throw new GObject.NotImplementedError(); } Pause() { throw new GObject.NotImplementedError(); } Play() { throw new GObject.NotImplementedError(); } PlayPause() { throw new GObject.NotImplementedError(); } Seek(offset) { throw new GObject.NotImplementedError(); } SetPosition(trackId, position) { throw new GObject.NotImplementedError(); } Stop() { throw new GObject.NotImplementedError(); } }); /** * An aggregate of the org.mpris.MediaPlayer2 and org.mpris.MediaPlayer2.Player * interfaces. */ const PlayerProxy = GObject.registerClass({ GTypeName: 'GSConnectMPRISPlayer', }, class PlayerProxy extends Player { _init(name) { super._init(); this._application = new Gio.DBusProxy({ g_bus_type: Gio.BusType.SESSION, g_name: name, g_object_path: '/org/mpris/MediaPlayer2', g_interface_name: 'org.mpris.MediaPlayer2', }); this._applicationChangedId = this._application.connect( 'g-properties-changed', this._onPropertiesChanged.bind(this) ); this._player = new Gio.DBusProxy({ g_bus_type: Gio.BusType.SESSION, g_name: name, g_object_path: '/org/mpris/MediaPlayer2', g_interface_name: 'org.mpris.MediaPlayer2.Player', }); this._playerChangedId = this._player.connect( 'g-properties-changed', this._onPropertiesChanged.bind(this) ); this._playerSignalId = this._player.connect( 'g-signal', this._onSignal.bind(this) ); this._cancellable = new Gio.Cancellable(); } _onSignal(proxy, sender_name, signal_name, parameters) { try { if (signal_name !== 'Seeked') return; this.emit('Seeked', parameters.deepUnpack()[0]); } catch (e) { debug(e, proxy.g_name); } } _call(proxy, name, parameters = null) { proxy.call( name, parameters, Gio.DBusCallFlags.NO_AUTO_START, -1, this._cancellable, (proxy, result) => { try { proxy.call_finish(result); } catch (e) { Gio.DBusError.strip_remote_error(e); debug(e, proxy.g_name); } } ); } _get(proxy, name, fallback = null) { try { return proxy.get_cached_property(name).recursiveUnpack(); } catch (e) { return fallback; } } _set(proxy, name, value) { try { proxy.set_cached_property(name, value); proxy.call( 'org.freedesktop.DBus.Properties.Set', new GLib.Variant('(ssv)', [proxy.g_interface_name, name, value]), Gio.DBusCallFlags.NO_AUTO_START, -1, this._cancellable, (proxy, result) => { try { proxy.call_finish(result); } catch (e) { Gio.DBusError.strip_remote_error(e); debug(e, proxy.g_name); } } ); } catch (e) { debug(e, proxy.g_name); } } _onPropertiesChanged(proxy, changed, invalidated) { try { this.freeze_notify(); for (const name in changed.deepUnpack()) this.notify(name); this.thaw_notify(); } catch (e) { debug(e, proxy.g_name); } } /* * The org.mpris.MediaPlayer2 Interface */ get CanQuit() { return this._get(this._application, 'CanQuit', false); } get CanRaise() { return this._get(this._application, 'CanRaise', false); } get CanSetFullscreen() { return this._get(this._application, 'CanSetFullscreen', false); } get DesktopEntry() { return this._get(this._application, 'DesktopEntry', null); } get Fullscreen() { return this._get(this._application, 'Fullscreen', false); } set Fullscreen(mode) { this._set(this._application, 'Fullscreen', new GLib.Variant('b', mode)); } get HasTrackList() { return this._get(this._application, 'HasTrackList', false); } get Identity() { return this._get(this._application, 'Identity', _('Unknown')); } get SupportedMimeTypes() { return this._get(this._application, 'SupportedMimeTypes', []); } get SupportedUriSchemes() { return this._get(this._application, 'SupportedUriSchemes', []); } Quit() { this._call(this._application, 'Quit'); } Raise() { this._call(this._application, 'Raise'); } /* * The org.mpris.MediaPlayer2.Player Interface */ get CanControl() { return this._get(this._player, 'CanControl', false); } get CanGoNext() { return this._get(this._player, 'CanGoNext', false); } get CanGoPrevious() { return this._get(this._player, 'CanGoPrevious', false); } get CanPause() { return this._get(this._player, 'CanPause', false); } get CanPlay() { return this._get(this._player, 'CanPlay', false); } get CanSeek() { return this._get(this._player, 'CanSeek', false); } get LoopStatus() { return this._get(this._player, 'LoopStatus', 'None'); } set LoopStatus(status) { this._set(this._player, 'LoopStatus', new GLib.Variant('s', status)); } get MaximumRate() { return this._get(this._player, 'MaximumRate', 1.0); } get Metadata() { if (this._metadata === undefined) { this._metadata = { 'xesam:artist': [_('Unknown')], 'xesam:album': _('Unknown'), 'xesam:title': _('Unknown'), 'mpris:length': 0, }; } return this._get(this._player, 'Metadata', this._metadata); } get MinimumRate() { return this._get(this._player, 'MinimumRate', 1.0); } get PlaybackStatus() { return this._get(this._player, 'PlaybackStatus', 'Stopped'); } // g-properties-changed is not emitted for this property get Position() { try { const reply = this._player.call_sync( 'org.freedesktop.DBus.Properties.Get', new GLib.Variant('(ss)', [ 'org.mpris.MediaPlayer2.Player', 'Position', ]), Gio.DBusCallFlags.NONE, -1, null ); return reply.recursiveUnpack()[0]; } catch (e) { return 0; } } get Rate() { return this._get(this._player, 'Rate', 1.0); } set Rate(rate) { this._set(this._player, 'Rate', new GLib.Variant('d', rate)); } get Shuffle() { return this._get(this._player, 'Shuffle', false); } set Shuffle(mode) { this._set(this._player, 'Shuffle', new GLib.Variant('b', mode)); } get Volume() { return this._get(this._player, 'Volume', 1.0); } set Volume(level) { this._set(this._player, 'Volume', new GLib.Variant('d', level)); } Next() { this._call(this._player, 'Next'); } OpenUri(uri) { this._call(this._player, 'OpenUri', new GLib.Variant('(s)', [uri])); } Previous() { this._call(this._player, 'Previous'); } Pause() { this._call(this._player, 'Pause'); } Play() { this._call(this._player, 'Play'); } PlayPause() { this._call(this._player, 'PlayPause'); } Seek(offset) { this._call(this._player, 'Seek', new GLib.Variant('(x)', [offset])); } SetPosition(trackId, position) { this._call(this._player, 'SetPosition', new GLib.Variant('(ox)', [trackId, position])); } Stop() { this._call(this._player, 'Stop'); } destroy() { if (this._cancellable.is_cancelled()) return; this._cancellable.cancel(); this._application.disconnect(this._applicationChangedId); this._player.disconnect(this._playerChangedId); this._player.disconnect(this._playerSignalId); } }); /** * A manager for media players */ const Manager = GObject.registerClass({ GTypeName: 'GSConnectMPRISManager', Signals: { 'player-added': { param_types: [GObject.TYPE_OBJECT], }, 'player-removed': { param_types: [GObject.TYPE_OBJECT], }, 'player-changed': { param_types: [GObject.TYPE_OBJECT], }, 'player-seeked': { param_types: [GObject.TYPE_OBJECT, GObject.TYPE_INT64], }, }, }, class Manager extends GObject.Object { _init() { super._init(); // Asynchronous setup this._cancellable = new Gio.Cancellable(); this._connection = Gio.DBus.session; this._players = new Map(); this._paused = new Map(); this._nameOwnerChangedId = Gio.DBus.session.signal_subscribe( 'org.freedesktop.DBus', 'org.freedesktop.DBus', 'NameOwnerChanged', '/org/freedesktop/DBus', 'org.mpris.MediaPlayer2', Gio.DBusSignalFlags.MATCH_ARG0_NAMESPACE, this._onNameOwnerChanged.bind(this) ); this._loadPlayers(); } async _loadPlayers() { try { const reply = await this._connection.call( 'org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus', 'ListNames', null, null, Gio.DBusCallFlags.NONE, -1, this._cancellable); const names = reply.deepUnpack()[0]; for (let i = 0, len = names.length; i < len; i++) { const name = names[i]; if (!name.startsWith('org.mpris.MediaPlayer2')) continue; if (!name.includes('GSConnect')) this._addPlayer(name); } } catch (e) { if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) logError(e); } } _onNameOwnerChanged(connection, sender, object, iface, signal, parameters) { const [name, oldOwner, newOwner] = parameters.deepUnpack(); if (name.includes('GSConnect')) return; if (newOwner.length) this._addPlayer(name); else if (oldOwner.length) this._removePlayer(name); } async _addPlayer(name) { try { if (!this._players.has(name)) { const player = new PlayerProxy(name); await Promise.all([ player._application.init_async(GLib.PRIORITY_DEFAULT, this._cancellable), player._player.init_async(GLib.PRIORITY_DEFAULT, this._cancellable), ]); player.connect('notify', (player) => this.emit('player-changed', player)); player.connect('Seeked', this.emit.bind(this, 'player-seeked')); this._players.set(name, player); this.emit('player-added', player); } } catch (e) { debug(e, name); } } _removePlayer(name) { try { const player = this._players.get(name); if (player !== undefined) { this._paused.delete(name); this._players.delete(name); this.emit('player-removed', player); player.destroy(); } } catch (e) { debug(e, name); } } /** * Check for a player by its Identity. * * @param {string} identity - A player name * @return {boolean} %true if the player was found */ hasPlayer(identity) { for (const player of this._players.values()) { if (player.Identity === identity) return true; } return false; } /** * Get a player by its Identity. * * @param {string} identity - A player name * @return {GSConnectMPRISPlayer|null} A player or %null */ getPlayer(identity) { for (const player of this._players.values()) { if (player.Identity === identity) return player; } return null; } /** * Get a list of player identities. * * @return {string[]} A list of player identities */ getIdentities() { const identities = []; for (const player of this._players.values()) { const identity = player.Identity; if (identity) identities.push(identity); } return identities; } /** * A convenience function for pausing all players currently playing. */ pauseAll() { for (const [name, player] of this._players) { if (player.PlaybackStatus === 'Playing' && player.CanPause) { player.Pause(); this._paused.set(name, player); } } } /** * A convenience function for restarting all players paused with pauseAll(). */ unpauseAll() { for (const player of this._paused.values()) { if (player.PlaybackStatus === 'Paused' && player.CanPlay) player.Play(); } this._paused.clear(); } destroy() { if (this._cancellable.is_cancelled()) return; this._cancellable.cancel(); this._connection.signal_unsubscribe(this._nameOwnerChangedId); this._paused.clear(); this._players.forEach(player => player.destroy()); this._players.clear(); } }); /** * The service class for this component */ export default Manager;