dot/.local/share/gnome-shell/extensions/gsconnect@andyholmes.github.io/service/plugins/mpris.js

978 lines
25 KiB
JavaScript

'use strict';
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const PluginsBase = imports.service.plugins.base;
const DBus = imports.utils.dbus;
var Metadata = {
label: _('MPRIS'),
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
* https://github.com/JasonLG1979/gnome-shell-extensions-mediaplayer/wiki/Known-Player-Bugs
*/
var Plugin = GObject.registerClass({
GTypeName: 'GSConnectMPRISPlugin',
}, class Plugin extends PluginsBase.Plugin {
_init(device) {
super._init(device, 'mpris');
try {
this._mpris = this.service.components.get('mpris');
this._notifyPlayersId = this._mpris.connect(
'notify::players',
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)
);
this._players = new Map();
this._transferring = new WeakSet();
this._updating = new WeakSet();
} catch (e) {
this.destroy();
throw e;
}
}
handlePacket(packet) {
if (packet.type === 'kdeconnect.mpris.request') {
this._handleRequest(packet);
} else if (packet.type === 'kdeconnect.mpris') {
this._handleStatus(packet);
}
}
connected() {
super.connected();
this._requestPlayerList();
this._sendPlayerList();
}
disconnected() {
super.disconnected();
for (let [identity, player] of this._players.entries()) {
player.destroy();
this._players.delete(identity);
}
}
_handleStatus(packet) {
try {
if (packet.body.hasOwnProperty('playerList')) {
this._handlePlayerList(packet.body.playerList);
} else if (packet.body.hasOwnProperty('player')) {
this._handlePlayerState(packet.body);
}
} catch (e) {
debug(e, `${this.device.name}: MPRIS`);
}
}
/**
* Handle a player list update
*
* @param {array} playerList - A list of remote player names
*/
_handlePlayerList(playerList) {
for (let player of this._players.values()) {
if (!playerList.includes(player.Identity)) {
this._players.delete(player.Identity);
player.destroy();
}
}
for (let identity of playerList) {
this._device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: identity,
requestNowPlaying: true,
requestVolume: true
}
});
}
}
/**
* Handle a player state update
*
* @param {object} state - The body of a kdeconnect.mpris packet
*/
_handlePlayerState(state) {
let player = this._players.get(state.player);
if (player === undefined) {
player = new RemotePlayer(this.device, state);
this._players.set(state.player, player);
} else {
player.parseState(state);
}
}
/**
* Request the list of player identities
*/
_requestPlayerList() {
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
requestPlayerList: true
}
});
}
_handleRequest(packet) {
// A request for the list of players
if (packet.body.requestPlayerList) {
this._sendPlayerList();
// A request for an unknown player; send the list of players
} else if (!this._mpris.identities.includes(packet.body.player)) {
this._sendPlayerList();
// An album art request
} else if (packet.body.hasOwnProperty('albumArtUrl')) {
this._sendAlbumArt(packet);
// A player command
} else {
this._handleCommand(packet);
}
}
/**
* Handle an incoming player command or information request
*
* @param {kdeconnect.mpris.request} - A command for a specific player
*/
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;
// 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:
logError(new Error(`unknown action: ${packet.body.action}`));
}
}
// Player Properties
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')) {
let offset = (packet.body.SetPosition * 1000) - player.Position;
await player.Seek(offset);
}
// Information Request
let hasResponse = false;
let 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,
artist: _('Unknown'),
title: _('Unknown')
});
let metadata = player.Metadata;
if (metadata.hasOwnProperty('mpris:artUrl')) {
let file = Gio.File.new_for_uri(metadata['mpris:artUrl']);
response.body.albumArtUrl = file.get_uri();
}
if (metadata.hasOwnProperty('mpris:length')) {
let trackLen = Math.floor(metadata['mpris:length'] / 1000);
response.body.length = trackLen;
}
if (metadata.hasOwnProperty('xesam:artist')) {
let 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'];
}
response.body.nowPlaying = [
response.body.artist,
response.body.title
].join(' - ');
}
if (packet.body.hasOwnProperty('requestVolume')) {
hasResponse = true;
response.body.volume = player.Volume * 100;
}
if (hasResponse) {
this.device.sendPacket(response);
}
} catch (e) {
logError(e);
} 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) {
this.device.sendPacket({
type: 'kdeconnect.mpris',
body: {
player: player.Identity,
pos: Math.floor(player.Position / 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
let metadata = player.Metadata;
if (!metadata.hasOwnProperty('mpris:artUrl')) {
return;
}
let file = Gio.File.new_for_uri(metadata['mpris:artUrl']);
let request = Gio.File.new_for_uri(packet.body.albumArtUrl);
if (file.get_uri() !== request.get_uri()) {
throw RangeError(`invalid URI "${packet.body.albumArtUrl}"`);
}
// Start the transfer process
this._transferring.add(player);
let transfer = this.device.createTransfer({
input_stream: file.read(null),
size: file.query_info('standard::size', 0, null).get_size()
});
await transfer.upload({
type: 'kdeconnect.mpris',
body: {
transferringAlbumArt: true,
player: packet.body.player,
albumArtUrl: packet.body.albumArtUrl
}
});
} catch (e) {
debug(e, 'transferring album art');
} 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.identities;
}
this.device.sendPacket({
type: 'kdeconnect.mpris',
body: {
playerList: playerList,
supportAlbumArtPayload: true
}
});
}
destroy() {
try {
this._mpris.disconnect(this._notifyPlayersId);
this._mpris.disconnect(this._playerChangedId);
this._mpris.disconnect(this._playerSeekedId);
} catch (e) {
// Silence errors
}
for (let [identity, player] of this._players.entries()) {
player.destroy();
this._players.delete(identity);
}
super.destroy();
}
});
/*
* A class for mirroring a remote Media Player on DBus
*/
const MPRISIface = gsconnect.dbusinfo.lookup_interface('org.mpris.MediaPlayer2');
const MPRISPlayerIface = gsconnect.dbusinfo.lookup_interface('org.mpris.MediaPlayer2.Player');
var RemotePlayer = GObject.registerClass({
GTypeName: 'GSConnectMPRISRemotePlayer',
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
),
'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 RemotePlayer extends GObject.Object {
_init(device, initialState) {
super._init();
this._device = device;
this._isPlaying = false;
this._ownerId = 0;
this._connection = null;
this._applicationIface = null;
this._playerIface = null;
this.parseState(initialState);
}
async export() {
try {
if (this._ownerId === 0) {
if (!this._connection) {
this._connection = await DBus.newConnection();
}
if (!this._applicationIface) {
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) {
this._playerIface = new DBus.Interface({
g_instance: this,
g_connection: this._connection,
g_object_path: '/org/mpris/MediaPlayer2',
g_interface_info: MPRISPlayerIface
});
}
let 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) {
logError(e);
}
}
unexport() {
if (this._ownerId !== 0) {
Gio.bus_unown_name(this._ownerId);
this._ownerId = 0;
}
if (this._applicationIface) {
this._applicationIface.destroy();
this._applicationIface = null;
}
if (this._playerIface) {
this._playerIface.destroy();
this._playerIface = null;
}
}
parseState(state) {
this._Identity = state.player;
// Metadata
let metadataChanged = false;
if (state.hasOwnProperty('title')) {
metadataChanged = true;
this._title = state.title;
}
if (state.hasOwnProperty('artist')) {
metadataChanged = true;
this._artist = state.artist;
}
if (state.hasOwnProperty('album')) {
metadataChanged = true;
this._album = state.album;
}
if (state.hasOwnProperty('length')) {
metadataChanged = true;
this._length = state.length * 1000;
}
// Probably a good idea to update this before emitting the length change
if (state.hasOwnProperty('pos')) {
this._Position = state.pos * 1000;
}
if (metadataChanged) this.notify('Metadata');
// 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('volume')) {
this.volume = state.volume / 100;
}
if (!this._isPlaying && !this.CanControl) {
this.unexport();
} else {
this.export();
}
}
/*
* Native properties
*/
get device() {
return this._device;
}
/*
* The org.mpris.MediaPlayer2 Interface
*/
get CanQuit() {
return false;
}
get Fullscreen() {
return false;
}
get CanSetFullscreen() {
return false;
}
get CanRaise() {
return false;
}
get HasTrackList() {
return false;
}
get Identity() {
return this._Identity;
}
get DesktopEntry() {
return 'org.gnome.Shell.Extensions.GSConnect';
}
get SupportedUriSchemes() {
return [];
}
get SupportedMimeTypes() {
return [];
}
Raise() {
}
Quit() {
}
/*
* The org.mpris.MediaPlayer2.Player Interface
*/
// 'Playing', 'Paused', 'Stopped'
get PlaybackStatus() {
if (this._isPlaying) {
return 'Playing';
} else {
return 'Stopped';
}
}
// 'None', 'Track', 'Playlist'
get LoopStatus() {
return 'None';
}
set LoopStatus(status) {
this.notify('LoopStatus');
}
get Rate() {
return 1.0;
}
set Rate(rate) {
this.notify('Rate');
}
get Shuffle() {
return false;
}
set Shuffle(mode) {
this.notify('Shuffle');
}
get Metadata() {
if (this._metadata === undefined) {
this._metadata = {};
}
Object.assign(this._metadata, {
'xesam:artist': new GLib.Variant('as', [this._artist || '']),
'xesam:album': new GLib.Variant('s', this._album || ''),
'xesam:title': new GLib.Variant('s', this._title || ''),
'mpris:length': new GLib.Variant('x', this._length || 0)
});
return this._metadata;
}
get Volume() {
if (this._Volume === undefined) {
this._Volume = 1.0;
}
return this._Volume;
}
set Volume(level) {
if (this._Volume !== level) {
this._Volume = level;
this.notify('Volume');
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
setVolume: this.Volume * 100
}
});
}
}
get Position() {
if (this._Position === undefined) {
this._Position = 0;
}
return this._Position;
}
get MinimumRate() {
return 1.0;
}
get MaximumRate() {
return 1.0;
}
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 CanPlay() {
if (this._CanPlay === undefined) {
this._CanPlay = false;
}
return this._CanPlay;
}
get CanPause() {
if (this._CanPause === undefined) {
this._CanPause = false;
}
return this._CanPause;
}
get CanSeek() {
if (this._CanSeek === undefined) {
this._CanSeek = false;
}
return this._CanSeek;
}
get CanControl() {
if (this._CanControl === undefined) {
this._CanControl = false;
}
return (this.CanPlay || this.CanPause);
}
Next() {
if (!this.CanControl || !this.CanGoNext) return;
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
action: 'Next'
}
});
}
Previous() {
if (!this.CanControl || !this.CanGoPrevious) return;
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
action: 'Previous'
}
});
}
Pause() {
if (!this.CanControl || !this.CanGoPause) return;
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
action: 'Pause'
}
});
}
PlayPause() {
if (!this.CanControl || !this.CanPause) return;
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
action: 'PlayPause'
}
});
}
Stop() {
if (!this.CanControl) return;
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
action: 'Stop'
}
});
}
Play() {
if (!this.CanControl || !this.CanPlay) return;
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
action: 'Next'
}
});
}
Seek(offset) {
if (!this.CanControl || !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
}
});
}
OpenUri(uri) {
debug(`OpenUri(${uri}): Not Supported`);
}
destroy() {
if (this.__disposed === undefined) {
this.__disposed = true;
this.unexport();
if (this._connection) {
this._connection.close(null, null);
this._connection = null;
}
}
}
});