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

1004 lines
25 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';
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;