// 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'; import * as Components from '../components/index.js'; import Config from '../../config.js'; import * as DBus from '../utils/dbus.js'; import {Player} from '../components/mpris.js'; import Plugin from '../plugin.js'; export const Metadata = { label: _('MPRIS'), description: _('Bidirectional remote media playback control'), id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.MPRIS', incomingCapabilities: ['kdeconnect.mpris', 'kdeconnect.mpris.request'], outgoingCapabilities: ['kdeconnect.mpris', 'kdeconnect.mpris.request'], actions: {}, }; /** * MPRIS Plugin * https://github.com/KDE/kdeconnect-kde/tree/master/plugins/mpriscontrol * * See also: * https://specifications.freedesktop.org/mpris-spec/latest/ * https://github.com/GNOME/gnome-shell/blob/master/js/ui/mpris.js */ const MPRISPlugin = GObject.registerClass({ GTypeName: 'GSConnectMPRISPlugin', }, class MPRISPlugin extends Plugin { _init(device) { super._init(device, 'mpris'); this._players = new Map(); this._transferring = new WeakSet(); this._updating = new WeakSet(); this._mpris = Components.acquire('mpris'); this._playerAddedId = this._mpris.connect( 'player-added', this._sendPlayerList.bind(this) ); this._playerRemovedId = this._mpris.connect( 'player-removed', this._sendPlayerList.bind(this) ); this._playerChangedId = this._mpris.connect( 'player-changed', this._onPlayerChanged.bind(this) ); this._playerSeekedId = this._mpris.connect( 'player-seeked', this._onPlayerSeeked.bind(this) ); } connected() { super.connected(); this._requestPlayerList(); this._sendPlayerList(); } disconnected() { super.disconnected(); for (const [identity, player] of this._players) { this._players.delete(identity); player.destroy(); } } handlePacket(packet) { switch (packet.type) { case 'kdeconnect.mpris': this._handleUpdate(packet); break; case 'kdeconnect.mpris.request': this._handleRequest(packet); break; } } /** * Handle a remote player update. * * @param {Core.Packet} packet - A `kdeconnect.mpris` */ _handleUpdate(packet) { try { if (packet.body.hasOwnProperty('playerList')) this._handlePlayerList(packet.body.playerList); else if (packet.body.hasOwnProperty('player')) this._handlePlayerUpdate(packet); } catch (e) { debug(e, this.device.name); } } /** * Handle an updated list of remote players. * * @param {string[]} playerList - A list of remote player names */ _handlePlayerList(playerList) { // Destroy removed players before adding new ones for (const player of this._players.values()) { if (!playerList.includes(player.Identity)) { this._players.delete(player.Identity); player.destroy(); } } for (const identity of playerList) { if (!this._players.has(identity)) { const player = new PlayerRemote(this.device, identity); this._players.set(identity, player); } // Always request player updates; packets are cheap this.device.sendPacket({ type: 'kdeconnect.mpris.request', body: { player: identity, requestNowPlaying: true, requestVolume: true, }, }); } } /** * Handle an update for a remote player. * * @param {Object} packet - A `kdeconnect.mpris` packet */ _handlePlayerUpdate(packet) { const player = this._players.get(packet.body.player); if (player === undefined) return; if (packet.body.hasOwnProperty('transferringAlbumArt')) player.handleAlbumArt(packet); else player.update(packet.body); } /** * Request a list of remote players. */ _requestPlayerList() { this.device.sendPacket({ type: 'kdeconnect.mpris.request', body: { requestPlayerList: true, }, }); } /** * Handle a request for player information or action. * * @param {Core.Packet} packet - a `kdeconnect.mpris.request` * @return {undefined} no return value */ _handleRequest(packet) { // A request for the list of players if (packet.body.hasOwnProperty('requestPlayerList')) return this._sendPlayerList(); // A request for an unknown player; send the list of players if (!this._mpris.hasPlayer(packet.body.player)) return this._sendPlayerList(); // An album art request if (packet.body.hasOwnProperty('albumArtUrl')) return this._sendAlbumArt(packet); // A player command this._handleCommand(packet); } /** * Handle an incoming player command or information request * * @param {Core.Packet} packet - A `kdeconnect.mpris.request` */ async _handleCommand(packet) { if (!this.settings.get_boolean('share-players')) return; let player; try { player = this._mpris.getPlayer(packet.body.player); if (player === undefined || this._updating.has(player)) return; this._updating.add(player); // Player Actions if (packet.body.hasOwnProperty('action')) { switch (packet.body.action) { case 'PlayPause': case 'Play': case 'Pause': case 'Next': case 'Previous': case 'Stop': player[packet.body.action](); break; default: debug(`unknown action: ${packet.body.action}`); } } // Player Properties if (packet.body.hasOwnProperty('setLoopStatus')) player.LoopStatus = packet.body.setLoopStatus; if (packet.body.hasOwnProperty('setShuffle')) player.Shuffle = packet.body.setShuffle; if (packet.body.hasOwnProperty('setVolume')) player.Volume = packet.body.setVolume / 100; if (packet.body.hasOwnProperty('Seek')) await player.Seek(packet.body.Seek); if (packet.body.hasOwnProperty('SetPosition')) { // We want to avoid implementing this as a seek operation, // because some players seek a fixed amount for every // seek request, only respecting the sign of the parameter. // (Chrome, for example, will only seek ±5 seconds, regardless // what value is passed to Seek().) const position = packet.body.SetPosition; const metadata = player.Metadata; if (metadata.hasOwnProperty('mpris:trackid')) { const trackId = metadata['mpris:trackid']; await player.SetPosition(trackId, position * 1000); } else { await player.Seek(position * 1000 - player.Position); } } // Information Request let hasResponse = false; const response = { type: 'kdeconnect.mpris', body: { player: packet.body.player, }, }; if (packet.body.hasOwnProperty('requestNowPlaying')) { hasResponse = true; Object.assign(response.body, { pos: Math.floor(player.Position / 1000), isPlaying: (player.PlaybackStatus === 'Playing'), canPause: player.CanPause, canPlay: player.CanPlay, canGoNext: player.CanGoNext, canGoPrevious: player.CanGoPrevious, canSeek: player.CanSeek, loopStatus: player.LoopStatus, shuffle: player.Shuffle, // default values for members that will be filled conditionally albumArtUrl: '', length: 0, artist: '', title: '', album: '', nowPlaying: '', volume: 0, }); const metadata = player.Metadata; if (metadata.hasOwnProperty('mpris:artUrl')) { const file = Gio.File.new_for_uri(metadata['mpris:artUrl']); response.body.albumArtUrl = file.get_uri(); } if (metadata.hasOwnProperty('mpris:length')) { const trackLen = Math.floor(metadata['mpris:length'] / 1000); response.body.length = trackLen; } if (metadata.hasOwnProperty('xesam:artist')) { const artists = metadata['xesam:artist']; response.body.artist = artists.join(', '); } if (metadata.hasOwnProperty('xesam:title')) response.body.title = metadata['xesam:title']; if (metadata.hasOwnProperty('xesam:album')) response.body.album = metadata['xesam:album']; // Now Playing if (response.body.artist && response.body.title) { response.body.nowPlaying = [ response.body.artist, response.body.title, ].join(' - '); } else if (response.body.artist) { response.body.nowPlaying = response.body.artist; } else if (response.body.title) { response.body.nowPlaying = response.body.title; } else { response.body.nowPlaying = _('Unknown'); } } if (packet.body.hasOwnProperty('requestVolume')) { hasResponse = true; response.body.volume = Math.floor(player.Volume * 100); } if (hasResponse) this.device.sendPacket(response); } catch (e) { debug(e, this.device.name); } finally { this._updating.delete(player); } } _onPlayerChanged(mpris, player) { if (!this.settings.get_boolean('share-players')) return; this._handleCommand({ body: { player: player.Identity, requestNowPlaying: true, requestVolume: true, }, }); } _onPlayerSeeked(mpris, player, offset) { // TODO: although we can handle full seeked signals, kdeconnect-android // does not, and expects a position update instead this.device.sendPacket({ type: 'kdeconnect.mpris', body: { player: player.Identity, pos: Math.floor(player.Position / 1000), // Seek: Math.floor(offset / 1000), }, }); } async _sendAlbumArt(packet) { let player; try { // Reject concurrent requests for album art player = this._mpris.getPlayer(packet.body.player); if (player === undefined || this._transferring.has(player)) return; // Ensure the requested albumArtUrl matches the current mpris:artUrl const metadata = player.Metadata; if (!metadata.hasOwnProperty('mpris:artUrl')) return; const file = Gio.File.new_for_uri(metadata['mpris:artUrl']); const request = Gio.File.new_for_uri(packet.body.albumArtUrl); if (file.get_uri() !== request.get_uri()) throw RangeError(`invalid URI "${packet.body.albumArtUrl}"`); // Transfer the album art this._transferring.add(player); const transfer = this.device.createTransfer(); transfer.addFile({ type: 'kdeconnect.mpris', body: { transferringAlbumArt: true, player: packet.body.player, albumArtUrl: packet.body.albumArtUrl, }, }, file); await transfer.start(); } catch (e) { debug(e, this.device.name); } finally { this._transferring.delete(player); } } /** * Send the list of player identities and indicate whether we support * transferring album art */ _sendPlayerList() { let playerList = []; if (this.settings.get_boolean('share-players')) playerList = this._mpris.getIdentities(); this.device.sendPacket({ type: 'kdeconnect.mpris', body: { playerList: playerList, supportAlbumArtPayload: true, }, }); } destroy() { if (this._mpris !== undefined) { this._mpris.disconnect(this._playerAddedId); this._mpris.disconnect(this._playerRemovedId); this._mpris.disconnect(this._playerChangedId); this._mpris.disconnect(this._playerSeekedId); this._mpris = Components.release('mpris'); } for (const [identity, player] of this._players) { this._players.delete(identity); player.destroy(); } super.destroy(); } }); /* * A class for mirroring a remote Media Player on DBus */ const PlayerRemote = GObject.registerClass({ GTypeName: 'GSConnectMPRISPlayerRemote', }, class PlayerRemote extends Player { _init(device, identity) { super._init(); this._device = device; this._Identity = identity; this._isPlaying = false; this._artist = null; this._title = null; this._album = null; this._length = 0; this._artUrl = null; this._ownerId = 0; this._connection = null; this._applicationIface = null; this._playerIface = null; } _getFile(albumArtUrl) { const hash = GLib.compute_checksum_for_string(GLib.ChecksumType.MD5, albumArtUrl, -1); const path = GLib.build_filenamev([Config.CACHEDIR, hash]); return Gio.File.new_for_uri(`file://${path}`); } _requestAlbumArt(state) { if (this._artUrl === state.albumArtUrl) return; const file = this._getFile(state.albumArtUrl); if (file.query_exists(null)) { this._artUrl = file.get_uri(); this._Metadata = undefined; this.notify('Metadata'); } else { this.device.sendPacket({ type: 'kdeconnect.mpris.request', body: { player: this.Identity, albumArtUrl: state.albumArtUrl, }, }); } } _updateMetadata(state) { let metadataChanged = false; if (state.hasOwnProperty('artist')) { if (this._artist !== state.artist) { this._artist = state.artist; metadataChanged = true; } } else if (this._artist) { this._artist = null; metadataChanged = true; } if (state.hasOwnProperty('title')) { if (this._title !== state.title) { this._title = state.title; metadataChanged = true; } } else if (this._title) { this._title = null; metadataChanged = true; } if (state.hasOwnProperty('album')) { if (this._album !== state.album) { this._album = state.album; metadataChanged = true; } } else if (this._album) { this._album = null; metadataChanged = true; } if (state.hasOwnProperty('length')) { if (this._length !== state.length * 1000) { this._length = state.length * 1000; metadataChanged = true; } } else if (this._length) { this._length = 0; metadataChanged = true; } if (state.hasOwnProperty('albumArtUrl')) { this._requestAlbumArt(state); } else if (this._artUrl) { this._artUrl = null; metadataChanged = true; } if (metadataChanged) { this._Metadata = undefined; this.notify('Metadata'); } } async export() { try { if (this._connection === null) { this._connection = await DBus.newConnection(); const MPRISIface = Config.DBUS.lookup_interface('org.mpris.MediaPlayer2'); const MPRISPlayerIface = Config.DBUS.lookup_interface('org.mpris.MediaPlayer2.Player'); if (this._applicationIface === null) { this._applicationIface = new DBus.Interface({ g_instance: this, g_connection: this._connection, g_object_path: '/org/mpris/MediaPlayer2', g_interface_info: MPRISIface, }); } if (this._playerIface === null) { this._playerIface = new DBus.Interface({ g_instance: this, g_connection: this._connection, g_object_path: '/org/mpris/MediaPlayer2', g_interface_info: MPRISPlayerIface, }); } } if (this._ownerId !== 0) return; const name = [ this.device.name, this.Identity, ].join('').replace(/[\W]*/g, ''); this._ownerId = Gio.bus_own_name_on_connection( this._connection, `org.mpris.MediaPlayer2.GSConnect.${name}`, Gio.BusNameOwnerFlags.NONE, null, null ); } catch (e) { debug(e, this.Identity); } } unexport() { if (this._ownerId === 0) return; Gio.bus_unown_name(this._ownerId); this._ownerId = 0; } /** * Download album art for the current track of the remote player. * * @param {Core.Packet} packet - A `kdeconnect.mpris` packet */ async handleAlbumArt(packet) { let file; try { file = this._getFile(packet.body.albumArtUrl); // Transfer the album art const transfer = this.device.createTransfer(); transfer.addFile(packet, file); await transfer.start(); this._artUrl = file.get_uri(); this._Metadata = undefined; this.notify('Metadata'); } catch (e) { debug(e, this.device.name); if (file) file.delete_async(GLib.PRIORITY_DEFAULT, null, null); } } /** * Update the internal state of the media player. * * @param {Core.Packet} state - The body of a `kdeconnect.mpris` packet */ update(state) { this.freeze_notify(); // Metadata if (state.hasOwnProperty('nowPlaying') || state.hasOwnProperty('artist') || state.hasOwnProperty('title')) this._updateMetadata(state); // Playback Status if (state.hasOwnProperty('isPlaying')) { if (this._isPlaying !== state.isPlaying) { this._isPlaying = state.isPlaying; this.notify('PlaybackStatus'); } } if (state.hasOwnProperty('canPlay')) { if (this.CanPlay !== state.canPlay) { this._CanPlay = state.canPlay; this.notify('CanPlay'); } } if (state.hasOwnProperty('canPause')) { if (this.CanPause !== state.canPause) { this._CanPause = state.canPause; this.notify('CanPause'); } } if (state.hasOwnProperty('canGoNext')) { if (this.CanGoNext !== state.canGoNext) { this._CanGoNext = state.canGoNext; this.notify('CanGoNext'); } } if (state.hasOwnProperty('canGoPrevious')) { if (this.CanGoPrevious !== state.canGoPrevious) { this._CanGoPrevious = state.canGoPrevious; this.notify('CanGoPrevious'); } } if (state.hasOwnProperty('pos')) this._Position = state.pos * 1000; if (state.hasOwnProperty('volume')) { if (this.Volume !== state.volume / 100) { this._Volume = state.volume / 100; this.notify('Volume'); } } this.thaw_notify(); if (!this._isPlaying && !this.CanControl) this.unexport(); else this.export(); } /* * Native properties */ get device() { return this._device; } /* * The org.mpris.MediaPlayer2.Player Interface */ get CanControl() { return (this.CanPlay || this.CanPause); } get Metadata() { if (this._Metadata === undefined) { this._Metadata = {}; if (this._artist) { this._Metadata['xesam:artist'] = new GLib.Variant('as', [this._artist]); } if (this._title) { this._Metadata['xesam:title'] = new GLib.Variant('s', this._title); } if (this._album) { this._Metadata['xesam:album'] = new GLib.Variant('s', this._album); } if (this._artUrl) { this._Metadata['mpris:artUrl'] = new GLib.Variant('s', this._artUrl); } this._Metadata['mpris:length'] = new GLib.Variant('x', this._length); } return this._Metadata; } get PlaybackStatus() { if (this._isPlaying) return 'Playing'; return 'Stopped'; } get Volume() { if (this._Volume === undefined) this._Volume = 0.3; return this._Volume; } set Volume(level) { if (this._Volume === level) return; this._Volume = level; this.notify('Volume'); this.device.sendPacket({ type: 'kdeconnect.mpris.request', body: { player: this.Identity, setVolume: Math.floor(this._Volume * 100), }, }); } Next() { if (!this.CanGoNext) return; this.device.sendPacket({ type: 'kdeconnect.mpris.request', body: { player: this.Identity, action: 'Next', }, }); } Pause() { if (!this.CanPause) return; this.device.sendPacket({ type: 'kdeconnect.mpris.request', body: { player: this.Identity, action: 'Pause', }, }); } Play() { if (!this.CanPlay) return; this.device.sendPacket({ type: 'kdeconnect.mpris.request', body: { player: this.Identity, action: 'Play', }, }); } PlayPause() { if (!this.CanPlay && !this.CanPause) return; this.device.sendPacket({ type: 'kdeconnect.mpris.request', body: { player: this.Identity, action: 'PlayPause', }, }); } Previous() { if (!this.CanGoPrevious) return; this.device.sendPacket({ type: 'kdeconnect.mpris.request', body: { player: this.Identity, action: 'Previous', }, }); } Seek(offset) { if (!this.CanSeek) return; this.device.sendPacket({ type: 'kdeconnect.mpris.request', body: { player: this.Identity, Seek: offset, }, }); } SetPosition(trackId, position) { debug(`${this._Identity}: SetPosition(${trackId}, ${position})`); if (!this.CanControl || !this.CanSeek) return; this.device.sendPacket({ type: 'kdeconnect.mpris.request', body: { player: this.Identity, SetPosition: position / 1000, }, }); } Stop() { if (!this.CanControl) return; this.device.sendPacket({ type: 'kdeconnect.mpris.request', body: { player: this.Identity, action: 'Stop', }, }); } destroy() { this.unexport(); if (this._connection) { this._connection.close(null, null); this._connection = null; if (this._applicationIface) { this._applicationIface.destroy(); this._applicationIface = null; } if (this._playerIface) { this._playerIface.destroy(); this._playerIface = null; } } } }); export default MPRISPlugin;