2024-07-08 22:46:35 +02:00

918 lines
26 KiB
JavaScript
Executable File

// 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;