123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906 |
- // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
- //
- // SPDX-License-Identifier: GPL-2.0-or-later
- 'use strict';
- const Gio = imports.gi.Gio;
- const GLib = imports.gi.GLib;
- const GObject = imports.gi.GObject;
- const Components = imports.service.components;
- const Config = imports.config;
- const DBus = imports.service.utils.dbus;
- const MPRIS = imports.service.components.mpris;
- const PluginBase = imports.service.plugin;
- var 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
- */
- var Plugin = GObject.registerClass({
- GTypeName: 'GSConnectMPRISPlugin',
- }, class Plugin extends PluginBase.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 * 1000);
- if (packet.body.hasOwnProperty('SetPosition')) {
- const offset = (packet.body.SetPosition * 1000) - player.Position;
- await player.Seek(offset);
- }
- // 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 MPRIS.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;
- }
- }
- }
- });
|