diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..d1673a3 --- /dev/null +++ b/.babelrc @@ -0,0 +1,7 @@ +{ + "plugins": [ + [ "@babel/plugin-proposal-decorators", { "decoratorsBeforeExport": true, "legacy": false } ], + "@babel/plugin-proposal-class-properties", + "@babel/plugin-transform-async-to-generator" + ] +} diff --git a/.gitignore b/.gitignore index 59d842b..219fd48 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ +/dist +/doc +/package-lock.json +/yarn.lock +*.swp + # Logs logs *.log diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..e69de29 diff --git a/.tern-project b/.tern-project new file mode 100644 index 0000000..f71dc86 --- /dev/null +++ b/.tern-project @@ -0,0 +1,5 @@ +{ + "plugins": { + "node": {} + } +} diff --git a/examples/player.js b/examples/player.js index 53e5611..01319b3 100644 --- a/examples/player.js +++ b/examples/player.js @@ -1,4 +1,4 @@ -var Player = require('..'); +var Player = require('../dist'); var player = Player({ name: 'nodejs', @@ -9,7 +9,7 @@ var player = Player({ }); // Events -var events = ['raise', 'quit', 'next', 'previous', 'pause', 'playpause', 'stop', 'play', 'seek', 'position', 'open', 'volume']; +var events = ['raise', 'quit', 'next', 'previous', 'pause', 'playpause', 'stop', 'play', 'seek', 'position', 'open', 'volume', 'loopStatus', 'shuffle']; events.forEach(function (eventName) { player.on(eventName, function () { console.log('Event:', eventName, arguments); @@ -28,10 +28,14 @@ setTimeout(function () { 'mpris:artUrl': 'http://www.adele.tv/images/facebook/adele.jpg', 'xesam:title': 'Lolol', 'xesam:album': '21', - 'xesam:artist': 'Adele' + 'xesam:artist': ['Adele'] }; player.playbackStatus = 'Playing'; console.log('Now playing: Lolol - Adele - 21'); -}, 3000); \ No newline at end of file +}, 1000); + +setTimeout(() => { + player.seeked(0); +}, 2000); diff --git a/examples/playlists.js b/examples/playlists.js index f1495f6..51b6c17 100644 --- a/examples/playlists.js +++ b/examples/playlists.js @@ -5,7 +5,7 @@ var player = Player({ identity: 'Node.js media player', supportedUriSchemes: ['file'], supportedMimeTypes: ['audio/mpeg', 'application/ogg'], - supportedInterfaces: ['player', 'playlists'] + supportedInterfaces: ['playlists'] }); player.on('quit', function () { @@ -38,4 +38,4 @@ player.setPlaylists([ Name: 'The coolest playlist', Icon: '' } -]); \ No newline at end of file +]); diff --git a/examples/tracklist.js b/examples/tracklist.js new file mode 100644 index 0000000..c334e3f --- /dev/null +++ b/examples/tracklist.js @@ -0,0 +1,36 @@ +var Player = require('../dist'); + +var player = Player({ + name: 'nodejs', + identity: 'Node.js media player', + supportedUriSchemes: ['file'], + supportedMimeTypes: ['audio/mpeg', 'application/ogg'], + supportedInterfaces: ['trackList'] +}); + +// Events +var events = ['addTrack', 'removeTrack', 'goTo']; +events.forEach(function (eventName) { + player.on(eventName, function () { + console.log('Event:', eventName, arguments); + }); +}); + +player.tracks = [ + { + 'mpris:trackid': player.objectPath('track/0'), + 'mpris:length': 60 * 1000 * 1000, + 'mpris:artUrl': 'http://www.adele.tv/images/facebook/adele.jpg', + 'xesam:title': 'Lolol', + 'xesam:album': '21', + 'xesam:artist': 'Adele' + }, + { + 'mpris:trackid': player.objectPath('track/1'), + 'mpris:length': 60 * 1000 * 1000, + 'mpris:artUrl': 'file:///home/emersion/anime/waifu.jpg', + 'xesam:title': 'Shake It Off', + 'xesam:album': '21', + 'xesam:artist': 'Taylor Swift' + } +]; diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..7b28fcc --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,19 @@ +const gulp = require('gulp'); +const babel = require('gulp-babel'); +const sourcemaps = require('gulp-sourcemaps'); +var path = require('path'); + +function handleError(error) { + console.log(error.toString()); + this.emit('end'); + process.exit(1); +} + +gulp.task('default', () => + gulp.src('src/**/*.js') + .pipe(sourcemaps.init()) + .pipe(babel()) + .on('error', handleError) + .pipe(sourcemaps.write('.', { sourceRoot: path.join('../src/') })) + .pipe(gulp.dest('dist')) +); diff --git a/index.js b/index.js deleted file mode 100644 index 8fa4b9b..0000000 --- a/index.js +++ /dev/null @@ -1,573 +0,0 @@ -var DBus = require('dbus'); -var events = require('events'); -var util = require('util'); - -function Type(signature, name) { - return { type: signature, name: name }; -} -function lcfirst(str) { - return str[0].toLowerCase()+str.substr(1); -} - -function Player(opts) { - if (!(this instanceof Player)) return new Player(opts); - events.EventEmitter.call(this); - - var that = this; - - this.name = opts.name; - this.identity = opts.identity; - this.supportedUriSchemes = opts.supportedUriSchemes; - this.supportedMimeTypes = opts.supportedMimeTypes; - this.desktopEntry = opts.desktopEntry; - - this.supportedInterfaces = opts.supportedInterfaces || ['player']; - - this._properties = {}; - - this.init(); -} -util.inherits(Player, events.EventEmitter); - -Player.prototype.init = function () { - // Create a new service, object and interface - this.serviceName = 'org.mpris.MediaPlayer2.'+this.name; - this.service = DBus.registerService('session',this.serviceName); - this.obj = this.service.createObject('/org/mpris/MediaPlayer2'); - - // TODO: must be defined in dbus module - this.obj.propertyInterface.addSignal('PropertiesChanged', { - types: [Type('s', 'interface_name'), Type('a{sv}', 'changed_properties'), Type('as', 'invalidated_properties')] - }); - this.obj.propertyInterface.update(); - - // Init interfaces - this.interfaces = {}; - this._createRootInterface(); - if (this.supportedInterfaces.indexOf('player') >= 0) { - this._createPlayerInterface(); - } - if (this.supportedInterfaces.indexOf('trackList') >= 0) { - this._createTrackListInterface(); - } - if (this.supportedInterfaces.indexOf('playlists') >= 0) { - this._createPlaylistsInterface(); - } -}; - -Player.prototype.objectPath = function (subpath) { - return '/org/node/mediaplayer/'+this.name+'/'+(subpath || ''); -}; - -Player.prototype._addEventedProperty = function (iface, name) { - var that = this; - - var localName = lcfirst(name); - var currentValue = this[localName]; - - Object.defineProperty(this, localName, { - get: function () { - return that._properties[name]; - }, - set: function (newValue) { - that._properties[name] = newValue; - - var changed = {}; - changed[name] = newValue; - that.obj.propertyInterface.emitSignal('PropertiesChanged', iface, changed, []); - - }, - enumerable: true, - configurable: true - }); - - if (currentValue) { - this[localName] = currentValue; - } -}; - -Player.prototype._addEventedPropertiesList = function (iface, props) { - for (var i = 0; i < props.length; i++) { - this._addEventedProperty(iface, props[i]); - } -}; - -/** - * @see http://specifications.freedesktop.org/mpris-spec/latest/Media_Player.html - */ -Player.prototype._createRootInterface = function () { - var that = this; - var ifaceName = 'org.mpris.MediaPlayer2', - iface = this.obj.createInterface(ifaceName); - - // Methods - - iface.addMethod('Raise', {}, function (callback) { - that.emit('raise'); - callback(); - }); - iface.addMethod('Quit', {}, function (callback) { - that.emit('quit'); - callback(); - }); - - // Properties - - var eventedProps = ['Identity', 'Fullscreen', 'SupportedUriSchemes', 'SupportedMimeTypes']; - this._addEventedPropertiesList(ifaceName, eventedProps); - - iface.addProperty('CanQuit', { - type: Type('b'), - getter: function(callback) { - callback(null, (typeof that.canQuit != 'undefined') ? that.canQuit : true); - } - }); - iface.addProperty('Fullscreen', { - type: Type('b'), - getter: function(callback) { - callback(null, that.fulscreen || false); - }, - setter: function (value, next) { - if (!that.canSetFullscreen) return next(); - - that.fullscreen = value; - that.emit('fullscreen', value); - next(); - } - }); - iface.addProperty('CanRaise', { - type: Type('b'), - getter: function(callback) { - callback(null, (typeof that.canRaise != 'undefined') ? that.canRaise : true); - } - }); - iface.addProperty('CanSetFullscreen', { - type: Type('b'), - getter: function(callback) { - callback(null, (typeof that.canSetFullscreen != 'undefined') ? that.canSetFullscreen : false); - } - }); - iface.addProperty('HasTrackList', { - type: Type('b'), - getter: function(callback) { - callback(null, false); - } - }); - iface.addProperty('Identity', { - type: Type('s'), - getter: function(callback) { - callback(null, that.identity || ''); - } - }); - if (this.desktopEntry) { - // This property is optional - iface.addProperty('DesktopEntry', { - type: Type('s'), - getter: function(callback) { - callback(null, that.desktopEntry || ''); - } - }); - } - iface.addProperty('SupportedUriSchemes', { - type: Type('as'), - getter: function(callback) { - callback(null, that.supportedUriSchemes || []); - } - }); - iface.addProperty('SupportedMimeTypes', { - type: Type('as'), - getter: function(callback) { - callback(null, that.supportedMimeTypes || []); - } - }); - - iface.update(); - this.interfaces.root = iface; -}; - -/** - * @see http://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html - */ -Player.prototype._createPlayerInterface = function () { - var that = this; - var ifaceName = 'org.mpris.MediaPlayer2.Player', - iface = this.obj.createInterface(ifaceName); - - // Methods - var eventMethods = ['Next', 'Previous', 'Pause', 'PlayPause', 'Stop', 'Play']; - var addEventMethod = function (method) { - iface.addMethod(method, {}, function (callback) { - that.emit(method.toLowerCase()); - callback(); - }); - }; - for (var i = 0; i < eventMethods.length; i++) { - addEventMethod(eventMethods[i]); - } - - iface.addMethod('Seek', { in: [ Type('x', 'Offset') ] }, function (delta, callback) { - that.emit('seek', { delta: delta, position: that.position + delta }); - callback(); - }); - iface.addMethod('SetPosition', { in: [ Type('o', 'TrackId'), Type('x', 'Position') ] }, function (trackId, pos, callback) { - that.emit('position', { trackId: trackId, position: pos }); - callback(); - }); - iface.addMethod('OpenUri', { in: [ Type('s', 'Uri') ] }, function (uri, callback) { - that.emit('open', { uri: uri }); - callback(); - }); - - // Signals - iface.addSignal('Seeked', { - types: [Type('x', 'Position')] - }); - - // Properties - this.position = 0; - - var eventedProps = ['PlaybackStatus', 'LoopStatus', 'Rate', 'Shuffle', 'Metadata', 'Volume']; - this._addEventedPropertiesList(ifaceName, eventedProps); - - iface.addProperty('PlaybackStatus', { - type: Type('s'), - getter: function(callback) { - callback(null, that.playbackStatus || 'Stopped'); - } - }); - iface.addProperty('LoopStatus', { - type: Type('s'), - getter: function(callback) { - callback(null, that.loopStatus || 'None'); - }, - setter: function (value, next) { - that.loopStatus = value; - that.emit('loopStatus', value); - next(); - } - }); - iface.addProperty('Rate', { - type: Type('d'), - getter: function(callback) { - callback(null, that.rate || 1); - }, - setter: function (value, next) { - that.rate = value; - that.emit('rate', value); - next(); - } - }); - iface.addProperty('Shuffle', { - type: Type('b'), - getter: function(callback) { - callback(null, that.shuffle || false); - }, - setter: function (value, next) { - that.shuffle = value; - that.emit('shuffle', value); - next(); - } - }); - iface.addProperty('Metadata', { - type: Type('a{sv}'), - getter: function(callback) { - callback(null, that.metadata || {}); - } - }); - iface.addProperty('Volume', { - type: Type('d'), - getter: function(callback) { - callback(null, that.volume || 1); - }, - setter: function (value, next) { - that.volume = value; - that.emit('volume', value); - next(); - } - }); - iface.addProperty('Position', { - type: Type('x'), - getter: function(callback) { - callback(null, that.position || 0); - } - }); - iface.addProperty('MinimumRate', { - type: Type('d'), - getter: function(callback) { - callback(null, that.minimumRate || 1); - } - }); - iface.addProperty('MaximumRate', { - type: Type('d'), - getter: function(callback) { - callback(null, that.maximumRate || 1); - } - }); - iface.addProperty('CanGoNext', { - type: Type('b'), - getter: function(callback) { - callback(null, (typeof that.canGoNext != 'undefined') ? that.canGoNext : true); - } - }); - iface.addProperty('CanGoPrevious', { - type: Type('b'), - getter: function(callback) { - callback(null, (typeof that.canGoPrevious != 'undefined') ? that.canGoPrevious : true); - } - }); - iface.addProperty('CanPlay', { - type: Type('b'), - getter: function(callback) { - callback(null, (typeof that.canPlay != 'undefined') ? that.canPlay : true); - } - }); - iface.addProperty('CanPause', { - type: Type('b'), - getter: function(callback) { - callback(null, (typeof that.canPause != 'undefined') ? that.canPause : true); - } - }); - iface.addProperty('CanSeek', { - type: Type('b'), - getter: function(callback) { - callback(null, (typeof that.canSeek != 'undefined') ? that.canSeek : true); - } - }); - iface.addProperty('CanControl', { - type: Type('b'), - getter: function(callback) { - callback(null, (typeof that.canControl != 'undefined') ? that.canControl : true); - } - }); - - iface.update(); - this.interfaces.player = iface; -}; - -Player.prototype.seeked = function (delta) { - this.position += delta || 0; - this.interfaces.player.emitSignal('Seeked', this.position); -}; - -/** - * @see http://specifications.freedesktop.org/mpris-spec/latest/Track_List_Interface.html - */ -Player.prototype._createTrackListInterface = function () { - var that = this; - var ifaceName = 'org.mpris.MediaPlayer2.TrackList', - iface = this.obj.createInterface(ifaceName); - - this.tracks = []; - - // Methods - - iface.addMethod('GetTracksMetadata', { - in: [ Type('ao', 'TrackIds') ], - out: Type('aa{sv}', 'Metadata') - }, function (trackIds, callback) { - callback(null, that.tracks.filter(function (track) { - return (trackIds.indexOf(track['mpris:trackid']) >= 0); - })); - }); - - iface.addMethod('AddTrack', { - in: [ Type('s', 'Uri'), Type('o', 'AfterTrack'), Type('b', 'SetAsCurrent') ] - }, function (uri, afterTrack, setAsCurrent, callback) { - that.emit('addTrack', { - uri: uri, - afterTrack: afterTrack, - setAsCurrent: setAsCurrent - }); - callback(); - }); - - iface.addMethod('RemoveTrack', { in: [ Type('o', 'TrackId') ] }, function (trackId, callback) { - that.emit('removeTrack', trackId); - callback(); - }); - - iface.addMethod('GoTo', { in: [ Type('o', 'TrackId') ] }, function (trackId, callback) { - that.emit('goTo', trackId); - callback(); - }); - - // Signals - - iface.addSignal('TrackListReplaced', { - types: [Type('ao', 'Tracks'), Type('o', 'CurrentTrack')] - }); - - iface.addSignal('TrackAdded', { - types: [Type('a{sv}', 'Metadata'), Type('o', 'AfterTrack')] - }); - - iface.addSignal('TrackRemoved', { - types: [Type('o', 'TrackId')] - }); - - iface.addSignal('TrackMetadataChanged', { - types: [Type('o', 'TrackId'), Type('a{sv}', 'Metadata')] - }); - - // Properties - - iface.addProperty('Tracks', { - type: Type('ao'), - getter: function(callback) { - callback(null, that.tracks); - } - }); - - iface.addProperty('CanEditTracks', { - type: Type('b'), - getter: function(callback) { - callback(null, (typeof that.canEditTracks != 'undefined') ? that.canEditTracks : false); - } - }); - - iface.update(); - this.interfaces.trackList = iface; -}; - -Player.prototype.getTrackIndex = function (trackId) { - for (var i = 0; i < this.tracks.length; i++) { - var track = this.tracks[i]; - - if (track['mpris:trackid'] == trackId) { - return i; - } - } - - return -1; -}; - -Player.prototype.getTrack = function (trackId) { - return this.tracks[this.getTrackIndex(trackId)]; -}; - -Player.prototype.addTrack = function (track) { - this.tracks.push(track); - - var afterTrack = '/org/mpris/MediaPlayer2/TrackList/NoTrack'; - if (this.tracks.length > 2) { - afterTrack = this.tracks[this.tracks.length - 2]['mpris:trackid']; - } - that.interfaces.playlists.emitSignal('TrackAdded', afterTrack); -}; - -Player.prototype.removeTrack = function (trackId) { - var i = this.getTrackIndex(trackId); - this.tracks.splice(i, 1); - - that.interfaces.playlists.emitSignal('TrackRemoved', trackId); -}; - -/** - * @see http://specifications.freedesktop.org/mpris-spec/latest/Playlists_Interface.html - */ -Player.prototype._createPlaylistsInterface = function () { - var that = this; - var ifaceName = 'org.mpris.MediaPlayer2.Playlists', - iface = this.obj.createInterface(ifaceName); - - that.playlists = []; - - // Methods - - iface.addMethod('ActivatePlaylist', { in: [ Type('o', 'PlaylistId') ] }, function (playlistId, callback) { - that.emit('activatePlaylist', playlistId); - callback(); - }); - - iface.addMethod('GetPlaylists', { - in: [ Type('u', 'Index'), Type('u', 'MaxCount'), Type('s', 'Order'), Type('b', 'ReverseOrder') ], - out: Type('a(oss)', 'Playlists') - }, function (index, maxCount, order, reverseOrder, callback) { - var playlists = that.playlists.slice(index, maxCount).sort(function (a, b) { - var ret = 1; - - switch (order) { - case 'Alphabetical': - ret = (a.Name > b.Name) ? 1 : -1; - break; - //case 'CreationDate': - //case 'ModifiedDate': - //case 'LastPlayDate': - case 'UserDefined': - break; - } - - if (reverseOrder) ret = -ret; - return ret; - }); - - callback(null, playlists); - }); - - // Signals - - iface.addSignal('PlaylistChanged', { - types: [Type('(oss)', 'Playlist')] - }); - - // Properties - - this._addEventedPropertiesList(ifaceName, ['PlaylistCount', 'ActivePlaylist']); - - iface.addProperty('PlaylistCount', { - type: Type('u'), - getter: function(callback) { - callback(null, that.playlistCount || 0); - } - }); - - iface.addProperty('Orderings', { - type: Type('as'), - getter: function(callback) { - callback(null, ['Alphabetical', 'UserDefined']); - } - }); - - iface.addProperty('ActivePlaylist', { - type: Type('(b(oss))'), - getter: function(callback) { - callback(null, that.activePlaylist || { Valid: false }); - } - }); - - iface.update(); - this.interfaces.playlists = iface; -}; - -Player.prototype.getPlaylistIndex = function (playlistId) { - for (var i = 0; i < this.playlists.length; i++) { - var playlist = this.playlists[i]; - - if (playlist.Id === playlistId) { - return i; - } - } - - return -1; -}; - -Player.prototype.setPlaylists = function (playlists) { - this.playlists = playlists; - this.playlistCount = playlists.length; - - var that = this; - this.playlists.forEach(function (playlist) { - that.interfaces.playlists.emitSignal('PlaylistChanged', playlist); - }); -}; - -Player.prototype.setActivePlaylist = function (playlistId) { - var i = this.getPlaylistIndex(playlistId); - - this.activePlaylist = { - Valid: (i >= 0) ? true : false, - Playlist: this.playlists[i] - }; -}; - -module.exports = Player; diff --git a/jsdoc.conf b/jsdoc.conf new file mode 100644 index 0000000..b828e00 --- /dev/null +++ b/jsdoc.conf @@ -0,0 +1,3 @@ +{ + "plugins": ["plugins/markdown"] +} diff --git a/package.json b/package.json index 28e56b8..38721db 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,12 @@ "name": "mpris-service", "version": "1.1.4", "description": "Node.js implementation for the MPRIS D-Bus Interface Specification to create a mediaplayer service", - "main": "index.js", + "main": "dist/index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "gulp && dbus-run-session -- jest", + "coverage": "gulp && dbus-run-session -- jest --coverage", + "build": "gulp", + "doc": "jsdoc --verbose -c jsdoc.conf --readme README.md --package package.json src/index.js -d doc" }, "repository": { "type": "git", @@ -23,6 +26,26 @@ }, "homepage": "https://github.com/emersion/mpris-service", "dependencies": { - "dbus": "^1.0.3" + "dbus-next": "0.2.1", + "source-map-support": "^0.5.9" + }, + "devDependencies": { + "@babel/core": "^7.1.6", + "@babel/plugin-proposal-class-properties": "^7.1.0", + "@babel/plugin-proposal-decorators": "^7.1.2", + "@babel/plugin-transform-async-to-generator": "^7.3.4", + "babel-core": "^7.0.0-bridge.0", + "babel-jest": "^23.6.0", + "gulp": "^4.0.0", + "gulp-babel": "^8.0.0", + "gulp-cli": "^2.0.1", + "gulp-sourcemaps": "^2.6.4", + "jest": "^23.6.0", + "jsbi": "^2.0.5", + "jsdoc": "^3.5.5", + "regenerator-runtime": "^0.12.1" + }, + "engine": { + "node": ">=6.3.0" } } diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..51f2464 --- /dev/null +++ b/src/index.js @@ -0,0 +1,301 @@ +require('source-map-support').install(); + +const events = require('events'); +const util = require('util'); + +const dbus = require('dbus-next'); +dbus.setBigIntCompat(true); +const PlayerInterface = require('./interfaces/player'); +const RootInterface = require('./interfaces/root'); +const PlaylistsInterface = require('./interfaces/playlists'); +const TracklistInterface = require('./interfaces/tracklist'); +const types = require('./interfaces/types'); + +const MPRIS_PATH = '/org/mpris/MediaPlayer2'; + +function lcfirst(str) { + return str[0].toLowerCase()+str.substr(1); +} + +/** + * Construct a new Player and export it on the DBus session bus. + * + * For more information about the properties of this class, see [the MPRIS DBus Interface Specification](https://specifications.freedesktop.org/mpris-spec/latest/). + * + * Method Call Events + * ------------------ + * + * The Player is an `EventEmitter` that emits events when the corresponding + * methods are called on the DBus interface over the wire. + * + * The Player emits events whenever the corresponding methods on the DBus + * interface are called. + * + * * `raise` - Brings the media player's user interface to the front using any appropriate mechanism available. + * * `quit` - Causes the media player to stop running. + * * `next` - Skips to the next track in the tracklist. + * * `previous` - Skips to the previous track in the tracklist. + * * `pause` - Pauses playback. + * * `playPause` - Pauses playback. If playback is already paused, resumes playback. If playback is stopped, starts playback. + * * `stop` - Stops playback. + * * `play` - Starts or resumes playback. + * * `seek` - Seeks forward in the current track by the specified number of microseconds. With event data `{ delta, position }`. + * * `position` - Sets the current track position in microseconds. With event data `{ trackId, position }`. + * * `open` - Opens the Uri given as an argument. With event data `{ uri }`. + * * `activatePlaylist` - Starts playing the given playlist. With event data `playlistId`. + * + * + * @class Player + * @param {Object} options - Options for the player + * @param {String} options.name - Name on the bus to export to as `org.mpris.MediaPlayer2.{name}`. + * @param {String} options.identity - Identity for the player to display on the root media player interface. + * @param {Array} options.supportedMimeTypes - Mime types this player can open with the `org.mpris.MediaPlayer2.Open` method. + * @param {Array} options.supportedInterfaces - The interfaces this player supports. Can include `'player'`, `'playlists'`, and `'trackList'`. + * @property {String} identity - A friendly name to identify the media player to users. + * @property {Boolean} fullscreen - Whether the media player is occupying the fullscreen. + * @property {Array} supportedUriSchemes - The URI schemes supported by the media player. + * @property {Array} supportedMimeTypes - The mime-types supported by the media player. + * @property {Boolean} canQuit - Whether the player can quit. + * @property {Boolean} canRaise - Whether the player can raise. + * @property {Boolean} canSetFullscreen - Whether the player can be set to fullscreen. + * @property {Boolean} hasTrackList - Indicates whether the /org/mpris/MediaPlayer2 object implements the org.mpris.MediaPlayer2.TrackList interface. + * @property {String} desktopEntry - The basename of an installed .desktop file which complies with the Desktop entry specification, with the ".desktop" extension stripped. + * @property {String} playbackStatus - The current playback status. May be "Playing", "Paused" or "Stopped". + * @property {String} loopStatus - The current loop/repeat status. May be "None", "Track", or "Playlist". + * @property {Boolean} shuffle - Whether the player is shuffling. + * @property {Object} metadata - The metadata of the current element. If there is a current track, this must have a "mpris:trackid" entry (of D-Bus type "o") at the very least, which contains a D-Bus path that uniquely identifies this track. + * @property {Double} volume - The volume level. + * @property {Boolean} canControl - Whether the media player may be controlled over this interface. + * @property {Boolean} canPause - Whether playback can be paused using Pause or PlayPause. + * @property {Boolean} canPlay - Whether playback can be started using Play or PlayPause. + * @property {Boolean} canSeek - Whether the client can control the playback position using Seek and SetPosition. + * @property {Boolean} canGoNext - Whether the client can call the Next method on this interface and expect the current track to change. + * @property {Boolean} canGoPrevious - Whether the client can call the Previous method on this interface and expect the current track to change. + * @property {Double} rate - The current playback rate. + * @property {Double} minimumRate - The minimum value which the Rate property can take. + * @property {Double} maximumRate - The maximum value which the Rate property can take. + * @property {Array} playlists - The current playlists set by Player#setPlaylists. (Not a DBus property). + * @property {Integer} activePlaylist - The currently-active playlist. + */ +function Player(opts) { + if (!(this instanceof Player)) { + return new Player(opts); + } + + events.EventEmitter.call(this); + this.name = opts.name; + this.supportedInterfaces = opts.supportedInterfaces || ['player']; + this._tracks = []; + this.position = 0; + this.init(opts); +} +util.inherits(Player, events.EventEmitter); + +Player.prototype.init = function(opts) { + this.serviceName = `org.mpris.MediaPlayer2.${this.name}`; + this._bus = dbus.sessionBus(); + + this.interfaces = {}; + + this._addRootInterface(this._bus, opts); + + if (this.supportedInterfaces.indexOf('player') >= 0) { + this._addPlayerInterface(this._bus); + } + if (this.supportedInterfaces.indexOf('trackList') >= 0) { + this._addTracklistInterface(this._bus); + } + if (this.supportedInterfaces.indexOf('playlists') >= 0) { + this._addPlaylistsInterface(this._bus); + } +}; + +Player.prototype._addRootInterface = function(bus, opts) { + this.interfaces.root = new RootInterface(this, opts); + this._addEventedPropertiesList(this.interfaces.root, + ['Identity', 'Fullscreen', 'SupportedUriSchemes', 'SupportedMimeTypes', + 'CanQuit', 'CanRaise', 'CanSetFullscreen', 'HasTrackList', + 'DesktopEntry']); + bus.export(this.serviceName, MPRIS_PATH, this.interfaces.root); +}; + +Player.prototype._addPlayerInterface = function(bus) { + this.interfaces.player = new PlayerInterface(this); + let eventedProps = ['PlaybackStatus', 'LoopStatus', 'Rate', 'Shuffle', + 'Metadata', 'Volume', 'CanControl', 'CanPause', 'CanPlay', 'CanSeek', + 'CanGoNext', 'CanGoPrevious', 'MinimumRate', 'MaximumRate']; + this._addEventedPropertiesList(this.interfaces.player, eventedProps); + bus.export(this.serviceName, MPRIS_PATH, this.interfaces.player); +}; + +Player.prototype._addTracklistInterface = function(bus) { + this.interfaces.tracklist = new TracklistInterface(this); + this._addEventedPropertiesList(this.interfaces.tracklist, ['CanEditTracks']); + + Object.defineProperty(this, 'tracks', { + get: function() { + return this._tracks; + }, + set: function(value) { + this._tracks = value; + this.interfaces.tracklist.TrackListReplaced(value); + }, + enumerable: true, + configurable: true + }); + + bus.export(this.serviceName, MPRIS_PATH, this.interfaces.tracklist); +}; + +Player.prototype._addPlaylistsInterface = function(bus) { + this.interfaces.playlists = new PlaylistsInterface(this); + this._addEventedPropertiesList(this.interfaces.playlists, + ['PlaylistCount', 'ActivePlaylist']); + bus.export(this.serviceName, MPRIS_PATH, this.interfaces.playlists); +} + +/** + * Get a valid object path with the `subpath` as the basename which is suitable + * for use as an id. + * + * @name Player#objectPath + * @function + * @param {String} subpath - The basename of this path + * @returns {String} - A valid object path that can be used as an id. + */ +Player.prototype.objectPath = function(subpath) { + let path = `/org/node/mediaplayer/${this.name}`; + if (subpath) { + path += `/${subpath}`; + } + return path; +}; + +Player.prototype._addEventedProperty = function(iface, name) { + let that = this; + + let localName = lcfirst(name); + + Object.defineProperty(this, localName, { + get: function() { + let value = iface[name]; + if (name === 'ActivePlaylist') { + return types.playlistToPlain(value); + } else if (name === 'Metadata') { + return types.metadataToPlain(value); + } + return value; + }, + set: function(value) { + iface.setProperty(name, value); + }, + enumerable: true, + configurable: true + }); +}; + +Player.prototype._addEventedPropertiesList = function(iface, props) { + for (let i = 0; i < props.length; i++) { + this._addEventedProperty(iface, props[i]); + } +}; + +/** + * Sets the position of the player to `position + delta` and emits the `Seeked` + * DBus signal to listening clients. + * + * @name Player#seeked + * @function + * @param {Integer} delta - The change in position in microseconds. + */ +Player.prototype.seeked = function(delta) { + this.position += delta || 0; + this.interfaces.player.Seeked(this.position); +}; + +Player.prototype.getTrackIndex = function(trackId) { + for (let i = 0; i < this.tracks.length; i++) { + let track = this.tracks[i]; + + if (track['mpris:trackid'] === trackId) { + return i; + } + } + + return -1; +}; + +Player.prototype.getTrack = function(trackId) { + return this.tracks[this.getTrackIndex(trackId)]; +}; + +Player.prototype.addTrack = function(track) { + this.tracks.push(track); + this.interfaces.tracklist.setTracks(this.tracks); + + let afterTrack = '/org/mpris/MediaPlayer2/TrackList/NoTrack'; + if (this.tracks.length > 2) { + afterTrack = this.tracks[this.tracks.length - 2]['mpris:trackid']; + } + that.interfaces.tracklist.TrackAdded(afterTrack); +}; + +Player.prototype.removeTrack = function(trackId) { + let i = this.getTrackIndex(trackId); + this.tracks.splice(i, 1); + this.interfaces.tracklist.setTracks(this.tracks); + + that.interfaces.tracklist.TrackRemoved(trackId); +}; + +/** + * Get the index of a playlist entry in the list of Player#playlists from the + * given id. + * + * @name Player#getPlaylistIndex + * @function + * @param {String} playlistId - The id for the playlist entry. + */ +Player.prototype.getPlaylistIndex = function(playlistId) { + for (let i = 0; i < this.playlists.length; i++) { + let playlist = this.playlists[i]; + + if (playlist.Id === playlistId) { + return i; + } + } + + return -1; +}; + +/** + * Set the list of playlists advertised to listeners on the bus. Each playlist + * must have string members `Id`, `Name`, and `Icon`. + * + * @name Player#setPlaylists + * @function + * @param {Array} playlists - A list of playlists. + */ +Player.prototype.setPlaylists = function(playlists) { + this.playlists = playlists; + this.playlistCount = playlists.length; + + let that = this; + this.playlists.forEach(function(playlist) { + that.interfaces.playlists.PlaylistChanged(playlist); + }); +}; + +/** + * Set the playlist identified by `playlistId` to be the currently active + * playlist. + * + * @name Player#setActivePlaylist + * @function + * @param {String} playlistId - The id of the playlist to activate. + */ +Player.prototype.setActivePlaylist = function(playlistId) { + this.interfaces.playlists.setActivePlaylistId(playlistId); +}; + +module.exports = Player; diff --git a/src/interfaces/mpris-interface.js b/src/interfaces/mpris-interface.js new file mode 100644 index 0000000..32d0fda --- /dev/null +++ b/src/interfaces/mpris-interface.js @@ -0,0 +1,47 @@ +let dbus = require('dbus-next'); +let Variant = dbus.Variant; +let types = require('./types'); + +let { + Interface, property, method, signal, MethodError, + ACCESS_READ, ACCESS_WRITE, ACCESS_READWRITE +} = dbus.interface; + +class MprisInterface extends Interface { + constructor(name, player) { + super(name); + this.player = player; + } + + _setPropertyInternal(property, valueDbus) { + // nothing is currently settable internally that needs conversion to plain + this.player.emit(property[0].toLowerCase() + property.substr(1), valueDbus); + } + + setProperty(property, valuePlain) { + // convert the plain value to a dbus value (default to the plain value) + let valueDbus = valuePlain; + + if (property === 'Metadata') { + valueDbus = types.metadataToDbus(valuePlain); + } else if (property === 'ActivePlaylist') { + if (valuePlain) { + valueDbus = [ true, types.playlistToDbus(valuePlain) ]; + } else { + valueDbus = [ false, types.emptyPlaylist ]; + } + } else if (property === 'Tracks') { + valueDbus = + valuePlain.filter((t) => t['mpris:trackid']).map((t) => t['mpris:trackid']); + } + + if (this[`_${property}`] !== valueDbus) { + this[`_${property}`] = valueDbus; + let changedProperties = {}; + changedProperties[property] = valueDbus; + Interface.emitPropertiesChanged(this, changedProperties); + } + } +} + +module.exports = MprisInterface; diff --git a/src/interfaces/player.js b/src/interfaces/player.js new file mode 100644 index 0000000..75c4716 --- /dev/null +++ b/src/interfaces/player.js @@ -0,0 +1,188 @@ +const dbus = require('dbus-next'); +const MprisInterface = require('./mpris-interface'); +const Variant = dbus.Variant; +const JSBI = require('jsbi'); + +let { + property, method, signal, MethodError, + ACCESS_READ, ACCESS_WRITE, ACCESS_READWRITE +} = dbus.interface; + +class PlayerInterface extends MprisInterface { + constructor(player) { + super('org.mpris.MediaPlayer2.Player', player); + } + + _CanControl = true; + _CanPause = true; + _CanPlay = true; + _CanSeek = true; + _CanGoNext = true; + _CanGoPrevious = true; + _Metadata = {}; + _MaximumRate = 1; + _MinimumRate = 1; + _Rate = 1; + _Shuffle = false; + _Volume = 0; + _Position = 0; + _LoopStatus = 'None'; + _PlaybackStatus = 'Stopped'; + + @property({signature: 'b', access: ACCESS_READ}) + get CanControl() { + return this._CanControl; + } + + @property({signature: 'b', access: ACCESS_READ}) + get CanPause() { + return this._CanPause; + } + + @property({signature: 'b', access: ACCESS_READ}) + get CanPlay() { + return this._CanPlay; + } + + @property({signature: 'b', access: ACCESS_READ}) + get CanSeek() { + return this._CanSeek; + } + + @property({signature: 'b', access: ACCESS_READ}) + get CanGoNext() { + return this._CanGoNext; + } + + @property({signature: 'b', access: ACCESS_READ}) + get CanGoPrevious() { + return this._CanGoPrevious; + } + + @property({signature: 'a{sv}', access: ACCESS_READ}) + get Metadata() { + return this._Metadata; + } + + @property({signature: 'd'}) + get MaximumRate() { + return this._MaximumRate; + } + set MaximumRate(value) { + this._setPropertyInternal('MaximumRate', value); + } + + @property({signature: 'd'}) + get MinimumRate() { + return this._MinimumRate; + } + set MinimumRate(value) { + this._setPropertyInternal('MinimumRate', value); + } + + @property({signature: 'd'}) + get Rate() { + return this._Rate; + } + set Rate(value) { + this._setPropertyInternal('Rate', value); + } + + @property({signature: 'b'}) + get Shuffle() { + return this._Shuffle; + } + set Shuffle(value) { + this._setPropertyInternal('Shuffle', value); + } + + @property({signature: 'd'}) + get Volume() { + return this._Volume; + } + set Volume(value) { + this._setPropertyInternal('Volume', value); + } + + @property({signature: 'x', access: ACCESS_READ}) + get Position() { + return this._Position; + } + + @property({signature: 's'}) + get LoopStatus() { + return this._LoopStatus; + } + set LoopStatus(value) { + this._setPropertyInternal('LoopStatus', value); + } + + @property({signature: 's', access: ACCESS_READ}) + get PlaybackStatus() { + return this._PlaybackStatus; + } + + @method({}) + Next() { + this.player.emit('next'); + } + + @method({}) + Previous() { + this.player.emit('previous'); + } + + @method({}) + Pause() { + this.player.emit('pause'); + } + + @method({}) + PlayPause() { + this.player.emit('playpause'); + } + + @method({}) + Stop() { + this.player.emit('stop'); + } + + @method({}) + Play() { + this.player.emit('play'); + } + + @method({inSignature: 'x'}) + Seek(offset) { + // XXX overflow + offset = JSBI.toNumber(offset); + let e = { + delta: offset, + position: (this.player.position || 0) + offset + }; + this.player.emit('seek', e); + } + + @method({inSignature: 'ox'}) + SetPosition(trackId, position) { + let e = { + trackId: trackId, + // XXX overflow + position: JSBI.toNumber(position) + }; + this.player.emit('position', e); + } + + @method({inSignature: 's'}) + OpenUri(uri) { + let e = { uri }; + this.player.emit('open', e); + } + + @signal({signature: 'x'}) + Seeked(position) { + return position; + } +} + +module.exports = PlayerInterface; diff --git a/src/interfaces/playlists.js b/src/interfaces/playlists.js new file mode 100644 index 0000000..2b30fe4 --- /dev/null +++ b/src/interfaces/playlists.js @@ -0,0 +1,82 @@ +// TODO proper import +let MprisInterface = require('./mpris-interface'); +let dbus = require('dbus-next'); +let Variant = dbus.Variant; +let types = require('./types'); + +let { + property, method, signal, MethodError, + ACCESS_READ, ACCESS_WRITE, ACCESS_READWRITE +} = dbus.interface; + +class PlaylistsInterface extends MprisInterface { + constructor(player) { + super('org.mpris.MediaPlayer2.Playlists', player); + } + + _ActivePlaylist = [ false, types.emptyPlaylist ]; + _PlaylistCount = 0; + + @property({signature: 'u', access: ACCESS_READ}) + get PlaylistCount() { + return this._PlaylistCount; + } + + @property({signature: 'as', access: ACCESS_READ}) + get Orderings() { + return ['Alphabetical', 'UserDefined']; + } + + @property({signature: '(b(oss))', access: ACCESS_READ}) + get ActivePlaylist() { + return this._ActivePlaylist; + } + + setActivePlaylistId(playlistId) { + let i = this.player.getPlaylistIndex(playlistId); + + this.setProperty('ActivePlaylist', this.player.playlists[i] || null); + } + + @method({inSignature: 'o'}) + ActivatePlaylist(playlistId) { + this.player.emit('activatePlaylist', playlistId); + } + + @method({inSignature: 'uusb', outSignature: 'a(oss)'}) + GetPlaylists(index, maxCount, order, reverseOrder) { + if (!this.player.playlists) { + return []; + } + + let result = this.player.playlists.sort(function(a, b) { + let ret = 1; + switch (order) { + case 'Alphabetical': + ret = (a.Name > b.Name) ? 1 : -1; + break; + //case 'CreationDate': + //case 'ModifiedDate': + //case 'LastPlayDate': + case 'UserDefined': + break; + } + return ret; + }) + .slice(index, maxCount + index) + .map(types.playlistToDbus); + + if (reverseOrder) { + result.reverse(); + } + + return result; + } + + @signal({signature: '(oss)'}) + PlaylistChanged(playlist) { + return types.playlistToDbus(playlist); + } +} + +module.exports = PlaylistsInterface; diff --git a/src/interfaces/root.js b/src/interfaces/root.js new file mode 100644 index 0000000..a7c9c23 --- /dev/null +++ b/src/interfaces/root.js @@ -0,0 +1,98 @@ +let MprisInterface = require('./mpris-interface'); +let dbus = require('dbus-next'); +let Variant = dbus.Variant; + +let { + property, method, signal, MethodError, + ACCESS_READ, ACCESS_WRITE, ACCESS_READWRITE +} = dbus.interface; + +class RootInterface extends MprisInterface { + constructor(player, opts={}) { + super('org.mpris.MediaPlayer2', player); + + if (opts.hasOwnProperty('identity')) { + this._Identity = opts.identity; + } + if (opts.hasOwnProperty('supportedUriSchemes')) { + this._SupportedUriSchemes = opts.supportedUriSchemes; + } + if (opts.hasOwnProperty('supportedMimeTypes')) { + this._SupportedMimeTypes = opts.supportedMimeTypes; + } + if (opts.hasOwnProperty('desktopEntry')) { + this._DesktopEntry = opts.desktopEntry; + } + } + + _CanQuit = true; + _Fullscreen = false; + _CanSetFullscreen = false; + _CanRaise = true; + _HasTrackList = false; + _Identity = ''; + // TODO optional properties + _DesktopEntry = ''; + _SupportedUriSchemes = []; + _SupportedMimeTypes = []; + + @property({signature: 'b', access: ACCESS_READ}) + get CanQuit() { + return this._CanQuit; + } + + @property({signature: 'b'}) + get Fullscreen() { + return this._Fullscreen; + } + set Fullscreen(value) { + this._setPropertyInternal('Fullscreen', value); + } + + @property({signature: 'b', access: ACCESS_READ}) + get CanSetFullscreen() { + return this._CanSetFullscreen; + } + + @property({signature: 'b', access: ACCESS_READ}) + get CanRaise() { + return this._CanRaise; + } + + @property({signature: 'b', access: ACCESS_READ}) + get HasTrackList() { + return this._HasTrackList; + } + + @property({signature: 's', access: ACCESS_READ}) + get Identity() { + return this._Identity; + } + + @property({signature: 's', access: ACCESS_READ}) + get DesktopEntry() { + return this._DesktopEntry; + } + + @property({signature: 'as', access: ACCESS_READ}) + get SupportedUriSchemes() { + return this._SupportedUriSchemes; + } + + @property({signature: 'as', access: ACCESS_READ}) + get SupportedMimeTypes() { + return this._SupportedMimeTypes; + } + + @method({}) + Raise() { + this.player.emit('raise'); + } + + @method({}) + Quit() { + this.player.emit('quit'); + } +} + +module.exports = RootInterface; diff --git a/src/interfaces/tracklist.js b/src/interfaces/tracklist.js new file mode 100644 index 0000000..32d8885 --- /dev/null +++ b/src/interfaces/tracklist.js @@ -0,0 +1,84 @@ +let MprisInterface = require('./mpris-interface'); +let dbus = require('dbus-next'); +let Variant = dbus.Variant; +let types = require('./types'); + +let { + property, method, signal, MethodError, + ACCESS_READ, ACCESS_WRITE, ACCESS_READWRITE +} = dbus.interface; + +class TracklistInterface extends MprisInterface { + constructor(player) { + super('org.mpris.MediaPlayer2.TrackList', player); + } + + _Tracks = []; + _CanEditTracks = false; + + setTracks(tracksPlain) { + this.setProperty('Tracks', tracksPlain); + } + + @property({signature: 'ao', access: ACCESS_READ}) + get Tracks() { + return this._Tracks; + } + + @property({signature: 'b', access: ACCESS_READ}) + get CanEditTracks() { + return this._CanEditTracks; + } + + @method({inSignature: 'ao', outSignature: 'aa{sv}'}) + GetTracksMetadata(trackIds) { + return this.player.tracks.filter((t) => { + return trackIds.some((id) => id === t['mpris:trackid']); + }).map(types.metadataToDbus); + } + + @method({inSignature: 'sob'}) + AddTrack(uri, afterTrack, setAsCurrent) { + this.player.emit('addTrack', { uri, afterTrack, setAsCurrent }); + } + + @method({inSignature: 'o'}) + RemoveTrack(trackId) { + this.player.emit('removeTrack', trackId); + } + + @method({inSignature: 'o'}) + GoTo(trackId) { + this.player.emit('goTo', trackId); + } + + @signal({signature: 'aoo'}) + TrackListReplaced(replacedPlain) { + this.setTracks(replacedPlain); + // TODO what's the active track? + return [ + this._Tracks, + '/org/mpris/MediaPlayer2/TrackList/NoTrack' + ]; + } + + @signal({signature: 'a{sv}'}) + TrackAdded(metadata) { + return types.metadataToDbus(metadata); + } + + @signal({signature: 'o'}) + TrackRemoved(path) { + return path; + } + + @signal({signature: 'oa{sv}'}) + TrackMetadataChanged(path, metadata){ + return [ + path, + types.metadataToDbus(metadata) + ]; + } +} + +module.exports = TracklistInterface; diff --git a/src/interfaces/types.js b/src/interfaces/types.js new file mode 100644 index 0000000..05db5f9 --- /dev/null +++ b/src/interfaces/types.js @@ -0,0 +1,70 @@ +let Variant = require('dbus-next').Variant; + +function guessMetadataSignature(key, value) { + if (key === 'mpris:trackid') { + return 'o'; + } else if (key === 'mpris:length') { + return 'x'; + } else if (typeof value === 'string') { + return 's'; + } else if (typeof value === 'boolean') { + return 'b'; + } else if (typeof value === 'number') { + return 'd'; + } else if (Array.isArray(value) && value.every((v) => typeof v === 'string')) { + return 'as'; + } else { + // type not supported yet + console.error(`could not determine metadata type for ${key}: ${value}`); + return null; + } +} + +function metadataToPlain(metadataVariant) { + let metadataPlain = {}; + for (let k of Object.keys(metadataVariant)) { + let value = metadataVariant[k]; + if (value.constructor === Variant) { + metadataPlain[k] = value.value; + } else { + metadataPlain[k] = value; + } + } + return metadataPlain; +} + +function metadataToDbus(metadataPlain) { + let metadataVariant = {}; + for (let k of Object.keys(metadataPlain)) { + let value = metadataPlain[k]; + let signature = guessMetadataSignature(k, value); + if (signature) { + metadataVariant[k] = new Variant(signature, value); + } + } + return metadataVariant; +} + +let emptyPlaylist = ['/', '', '']; + +function playlistToDbus(playlist) { + if (!playlist) { + return emptyPlaylist; + } + + let { Id, Name, Icon } = playlist; + return [ Id, Name, Icon ]; +} + +function playlistToPlain(wire) { + let [ Id, Name, Icon ] = wire; + return { Id, Name, Icon }; +} + +module.exports = { + metadataToPlain, + metadataToDbus, + playlistToPlain, + playlistToDbus, + emptyPlaylist +}; diff --git a/test/player.test.js b/test/player.test.js new file mode 100644 index 0000000..1330739 --- /dev/null +++ b/test/player.test.js @@ -0,0 +1,216 @@ +let dbus = require('dbus-next'); +let Variant = dbus.Variant; +let Player = require('../dist'); +let JSBI = require('jsbi'); + +const ROOT_IFACE = 'org.mpris.MediaPlayer2'; +const PLAYER_IFACE = 'org.mpris.MediaPlayer2.Player'; + +let lcFirst = (str) => { + return str.charAt(0).toLowerCase() + str.slice(1); +}; + +var player = Player({ + name: 'playertest', + identity: 'Node.js media player', + supportedUriSchemes: ['file'], + supportedMimeTypes: ['audio/mpeg', 'application/ogg'], + supportedInterfaces: ['player'] +}); + +let bus = dbus.sessionBus(); + +afterAll(() => { + player._bus.connection.stream.end(); + bus.connection.stream.end(); +}); + +test('creating a player exports the root and player interfaces on the bus', async () => { + let dbusObj = await bus.getProxyObject('org.freedesktop.DBus', '/org/freedesktop/DBus'); + let dbusIface = dbusObj.getInterface('org.freedesktop.DBus'); + let names = await dbusIface.ListNames(); + expect(names).toEqual(expect.arrayContaining(['org.mpris.MediaPlayer2.playertest'])); + + let obj = await bus.getProxyObject('org.mpris.MediaPlayer2.playertest', '/org/mpris/MediaPlayer2'); + let expectedInterfaces = [ + 'org.freedesktop.DBus.Introspectable', + 'org.freedesktop.DBus.Properties', + ROOT_IFACE, + PLAYER_IFACE + ]; + for (let expected of expectedInterfaces) { + expect(obj.getInterface(expected)).toBeDefined(); + } +}); + +test('calling the player methods on the bus emits the signals on the object', async () => { + let obj = await bus.getProxyObject('org.mpris.MediaPlayer2.playertest', '/org/mpris/MediaPlayer2'); + let playerIface = obj.getInterface(PLAYER_IFACE); + + // simple commands called with no event + let commands = [ 'Play', 'Pause', 'PlayPause', 'Stop', 'Next', 'Previous' ]; + for (let cmd of commands) { + let cb = jest.fn(); + player.once(cmd.toLowerCase(), cb); + await playerIface[cmd](); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith(); + } + + // OpenUri + let cb = jest.fn(); + player.once('open', cb); + await playerIface.OpenUri('file://somefile'); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith({ uri: 'file://somefile' }); +}); + +test('getting and setting properties on the player and on the interface should work', async () => { + let obj = await bus.getProxyObject('org.mpris.MediaPlayer2.playertest', '/org/mpris/MediaPlayer2'); + let dbusObj = await bus.getProxyObject('org.freedesktop.DBus', '/org/freedesktop/DBus'); + + let ping = async () => { + let peer = dbusObj.getInterface('org.freedesktop.DBus.Peer'); + return peer.Ping(); + }; + + let playerIface = obj.getInterface(PLAYER_IFACE); + let props = obj.getInterface('org.freedesktop.DBus.Properties'); + + let cb = jest.fn(); + props.on('PropertiesChanged', cb); + + // Metadata + player.metadata = { + 'xesam:artist': ['Katy Perry'], + 'xesam:title': 'Rise' + }; + await ping(); + let changed = { + Metadata: new Variant('a{sv}', { + 'xesam:artist': new Variant('as', ['Katy Perry']), + 'xesam:title': new Variant('s', 'Rise') + }) + } + expect(cb).toHaveBeenLastCalledWith(PLAYER_IFACE, changed, []); + let gotten = await props.Get(PLAYER_IFACE, 'Metadata'); + expect(gotten).toEqual(changed.Metadata); + + // PlaybackStatus + player.playbackStatus = 'Paused'; + await ping(); + changed = { + PlaybackStatus: new Variant('s', 'Paused') + }; + expect(cb).toHaveBeenLastCalledWith(PLAYER_IFACE, changed, []); + gotten = await props.Get(PLAYER_IFACE, 'PlaybackStatus'); + expect(gotten).toEqual(new Variant('s', 'Paused')); + + // LoopStatus + player.loopStatus = 'Track'; + await ping(); + changed = { + LoopStatus: new Variant('s', 'Track') + }; + expect(cb).toHaveBeenLastCalledWith(PLAYER_IFACE, changed, []); + gotten = await props.Get(PLAYER_IFACE, 'LoopStatus'); + expect(gotten).toEqual(new Variant('s', 'Track')); + let playerCb = jest.fn(val => { + player.loopStatus = val; + }); + player.once('loopStatus', playerCb); + await props.Set(PLAYER_IFACE, 'LoopStatus', new Variant('s', 'Playlist')); + expect(playerCb).toHaveBeenCalledWith('Playlist'); + changed = { + LoopStatus: new Variant('s', 'Playlist') + }; + expect(cb).toHaveBeenLastCalledWith(PLAYER_IFACE, changed, []); + expect(player.loopStatus).toEqual('Playlist'); + + // The Double Properties + let doubleProps = ['Rate', 'Volume', 'MinimumRate', 'MaximumRate']; + for (let name of doubleProps) { + let playerName = lcFirst(name); + player[playerName] = 0.05; + await ping(); + changed = {}; + changed[name] = new Variant('d', 0.05); + expect(cb).toHaveBeenLastCalledWith(PLAYER_IFACE, changed, []); + gotten = await props.Get(PLAYER_IFACE, name); + expect(gotten).toEqual(new Variant('d', player[playerName])); + let playerCb = jest.fn(val => { + player[playerName] = val; + }); + player.once(playerName, playerCb); + await props.Set(PLAYER_IFACE, name, new Variant('d', 0.15)); + expect(playerCb).toHaveBeenCalledWith(0.15); + expect(player[playerName]).toEqual(0.15); + changed[name] = new Variant('d', 0.15); + expect(cb).toHaveBeenLastCalledWith(PLAYER_IFACE, changed, []); + } + + // The Boolean properties + let boolProps = ['CanControl', 'CanPause', 'CanPlay', 'CanSeek', 'CanGoNext', + 'CanGoPrevious', 'Shuffle']; + for (let name of boolProps) { + let playerName = lcFirst(name); + let newValue = !player[playerName]; + player[playerName] = newValue; + await ping(); + changed = {}; + changed[name] = new Variant('b', newValue); + expect(cb).toHaveBeenLastCalledWith(PLAYER_IFACE, changed, []); + gotten = await props.Get(PLAYER_IFACE, name); + expect(gotten).toEqual(new Variant('b', player[playerName])); + if (name === 'Shuffle') { + let nextNewValue = !newValue; + // only this property is writable + let playerCb = jest.fn(val => { + player.shuffle = val; + }); + player.once('shuffle', playerCb); + await props.Set(PLAYER_IFACE, name, new Variant('b', nextNewValue)); + expect(playerCb).toHaveBeenCalledWith(nextNewValue); + expect(player[playerName]).toEqual(nextNewValue); + } + } +}); + +test('position specific properties, methods, and signals should work', async () => { + // note: they are responsible for setting the position, not the methods directly + let obj = await bus.getProxyObject('org.mpris.MediaPlayer2.playertest', '/org/mpris/MediaPlayer2'); + let dbusObj = await bus.getProxyObject('org.freedesktop.DBus', '/org/freedesktop/DBus'); + let playerIface = obj.getInterface(PLAYER_IFACE); + let props = obj.getInterface('org.freedesktop.DBus.Properties'); + + let ping = async () => { + let peer = dbusObj.getInterface('org.freedesktop.DBus.Peer'); + return peer.Ping(); + }; + + // position starts at 0 + let position = await props.Get(PLAYER_IFACE, 'Position'); + expect(position).toEqual(new Variant('x', JSBI.BigInt(0))); + + // Seek + let cb = jest.fn(e => { + player.seeked(e.delta); + }); + player.once('seek', cb); + await playerIface.Seek(99); + expect(cb).toHaveBeenCalledWith({ delta: 99, position: 99 }); + expect(player.position).toEqual(99); + + // SetPosition + cb = jest.fn(); + player.once('position', cb); + await playerIface.SetPosition('/some/track', 100); + expect(cb).toHaveBeenCalledWith({ trackId: '/some/track', position: 100 }); + + cb = jest.fn(); + playerIface.once('Seeked', cb); + player.seeked(99); + await ping(); + // this one updates position + expect(cb).toHaveBeenCalledWith(JSBI.BigInt(198)); +}); diff --git a/test/playlists.test.js b/test/playlists.test.js new file mode 100644 index 0000000..378d58d --- /dev/null +++ b/test/playlists.test.js @@ -0,0 +1,184 @@ +let dbus = require('dbus-next'); +let Variant = dbus.Variant; +let Player = require('../dist'); +let JSBI = require('jsbi'); + +const ROOT_IFACE = 'org.mpris.MediaPlayer2'; +const PLAYER_IFACE = 'org.mpris.MediaPlayer2.Player'; +const PLAYLISTS_IFACE = 'org.mpris.MediaPlayer2.Playlists'; + +let lcFirst = (str) => { + return str.charAt(0).toLowerCase() + str.slice(1); +}; + +var player = Player({ + name: 'playliststest', + identity: 'Node.js media player', + supportedUriSchemes: ['file'], + supportedMimeTypes: ['audio/mpeg', 'application/ogg'], + supportedInterfaces: ['player', 'playlists'] +}); + +let bus = dbus.sessionBus(); + +afterAll(() => { + player._bus.connection.stream.end(); + bus.connection.stream.end(); +}); + +test('creating a player exports the playlists interfaces on the bus', async () => { + let dbusObj = await bus.getProxyObject('org.freedesktop.DBus', '/org/freedesktop/DBus'); + let dbusIface = dbusObj.getInterface('org.freedesktop.DBus'); + let names = await dbusIface.ListNames(); + expect(names).toEqual(expect.arrayContaining(['org.mpris.MediaPlayer2.playliststest'])); + + let obj = await bus.getProxyObject('org.mpris.MediaPlayer2.playliststest', '/org/mpris/MediaPlayer2'); + let expectedInterfaces = [ + 'org.freedesktop.DBus.Introspectable', + 'org.freedesktop.DBus.Properties', + ROOT_IFACE, + PLAYLISTS_IFACE, + PLAYER_IFACE + ]; + for (let expected of expectedInterfaces) { + expect(obj.getInterface(expected)).toBeDefined(); + } +}); + +test('default state of the playlists interface', async () => { + let obj = await bus.getProxyObject('org.mpris.MediaPlayer2.playliststest', '/org/mpris/MediaPlayer2'); + let dbusObj = await bus.getProxyObject('org.freedesktop.DBus', '/org/freedesktop/DBus'); + let playlistsIface = obj.getInterface(PLAYLISTS_IFACE); + let props = obj.getInterface('org.freedesktop.DBus.Properties'); + + let playlistCount = await props.Get(PLAYLISTS_IFACE, 'PlaylistCount'); + expect(playlistCount).toEqual(new Variant('u', 0)); + + let orderings = await props.Get(PLAYLISTS_IFACE, 'Orderings'); + expect(orderings).toEqual(new Variant('as', ['Alphabetical', 'UserDefined'])); + + let activePlaylist = await props.Get(PLAYLISTS_IFACE, 'ActivePlaylist'); + expect(activePlaylist).toEqual(new Variant('(b(oss))', [ false, [ '/', '', '' ] ])); + + let playlists = await playlistsIface.GetPlaylists(0, 1, 'Alphabetical', false); + expect(playlists).toEqual([]); +}); + +test('setting a playlist on the player works', async () => { + let obj = await bus.getProxyObject('org.mpris.MediaPlayer2.playliststest', '/org/mpris/MediaPlayer2'); + let dbusObj = await bus.getProxyObject('org.freedesktop.DBus', '/org/freedesktop/DBus'); + let playlistsIface = obj.getInterface(PLAYLISTS_IFACE); + let props = obj.getInterface('org.freedesktop.DBus.Properties'); + let ping = async () => { + let peer = dbusObj.getInterface('org.freedesktop.DBus.Peer'); + return peer.Ping(); + }; + + let propsCb = jest.fn(); + props.on('PropertiesChanged', propsCb); + + let playlistChangedCb = jest.fn(); + playlistsIface.on('PlaylistChanged', playlistChangedCb); + + player.setPlaylists([ + { + Id: player.objectPath('playlist/0'), + Name: 'The best playlist', + Icon: '' + }, + { + Id: player.objectPath('playlist/1'), + Name: 'The wonderful playlist', + Icon: '' + }, + { + Id: player.objectPath('playlist/2'), + Name: 'The sexyiest playlist', + Icon: '' + }, + { + Id: player.objectPath('playlist/3'), + Name: 'The coolest playlist', + Icon: '' + } + ]); + + await ping(); + + expect(propsCb).toHaveBeenCalledWith(PLAYLISTS_IFACE, { + PlaylistCount: new Variant('u', 4) + }, []); + + for (playlist of player.playlists) { + expect(playlistChangedCb).toHaveBeenCalledWith([ + playlist.Id, + playlist.Name, + playlist.Icon + ]); + } + + player.setActivePlaylist(player.playlists[1].Id); + + await ping(); + + let expectedActivePlaylist = new Variant('(b(oss))', + [ + true, + [ + player.playlists[1].Id, + player.playlists[1].Name, + player.playlists[1].Icon + ] + ] + ); + + expect(propsCb).toHaveBeenLastCalledWith(PLAYLISTS_IFACE, { + ActivePlaylist: expectedActivePlaylist + }, []); + + let activePlaylist = await props.Get(PLAYLISTS_IFACE, 'ActivePlaylist'); + expect(activePlaylist).toEqual(expectedActivePlaylist); + + function playlistToDbus(p) { + return [ p.Id, p.Name, p.Icon ]; + } + // all userdefined + let dbusPlaylists = await playlistsIface.GetPlaylists(0, 99, 'UserDefined', false); + expect(player.playlists.map(playlistToDbus)).toEqual(dbusPlaylists); + + // all userdefined reverse + dbusPlaylists = await playlistsIface.GetPlaylists(0, 99, 'UserDefined', true); + dbusPlaylists.reverse(); + expect(player.playlists.map(playlistToDbus)).toEqual(dbusPlaylists); + + // userdefined slice and max + dbusPlaylists = await playlistsIface.GetPlaylists(1, 2, 'UserDefined', false); + expect(player.playlists.slice(1, 3).map(playlistToDbus)).toEqual(dbusPlaylists); + + // userdefined slice and max reverse + dbusPlaylists = await playlistsIface.GetPlaylists(1, 2, 'UserDefined', true); + dbusPlaylists.reverse(); + expect(player.playlists.slice(1, 3).map(playlistToDbus)).toEqual(dbusPlaylists); + + // all alphabetical + dbusPlaylists = await playlistsIface.GetPlaylists(0, 99, 'Alphabetical', false); + let expected = player.playlists.sort((a, b) => a < b).map(playlistToDbus); + expect(expected).toEqual(dbusPlaylists); + + // all alphabetical reverse + dbusPlaylists = await playlistsIface.GetPlaylists(0, 99, 'Alphabetical', true); + dbusPlaylists.reverse(); + expected = player.playlists.sort((a, b) => a < b).map(playlistToDbus); + expect(expected).toEqual(dbusPlaylists); + + // alphabetical slice and max + dbusPlaylists = await playlistsIface.GetPlaylists(1, 2, 'Alphabetical', false); + expected = player.playlists.sort((a, b) => a < b).slice(1, 3).map(playlistToDbus); + expect(expected).toEqual(dbusPlaylists); + + // alphabetical slice and max reverse + dbusPlaylists = await playlistsIface.GetPlaylists(1, 2, 'Alphabetical', true); + dbusPlaylists.reverse(); + expected = player.playlists.sort((a, b) => a < b).slice(1, 3).map(playlistToDbus); + expect(expected).toEqual(dbusPlaylists); +}); diff --git a/test/root.test.js b/test/root.test.js new file mode 100644 index 0000000..811f0c9 --- /dev/null +++ b/test/root.test.js @@ -0,0 +1,108 @@ +let dbus = require('dbus-next'); +let Variant = dbus.Variant; +let Player = require('../dist'); + +const ROOT_IFACE = 'org.mpris.MediaPlayer2'; +const PLAYER_IFACE = 'org.mpris.MediaPlayer2.Player'; + +let lcFirst = (str) => { + return str.charAt(0).toLowerCase() + str.slice(1); +}; + +var player = Player({ + name: 'roottest', + identity: 'Node.js media player', + supportedUriSchemes: ['file'], + supportedMimeTypes: ['audio/mpeg', 'application/ogg'], + supportedInterfaces: ['player'] +}); + +let bus = dbus.sessionBus(); + +afterAll(() => { + player._bus.connection.stream.end(); + bus.connection.stream.end(); +}); + +test('calling methods should raise a signal on the player', async () => { + let obj = await bus.getProxyObject('org.mpris.MediaPlayer2.roottest', '/org/mpris/MediaPlayer2'); + let root = obj.getInterface(ROOT_IFACE); + + let cb = jest.fn(); + player.once('quit', cb); + await root.Quit(); + expect(cb).toHaveBeenCalledWith(); + + cb = jest.fn(); + player.once('raise', cb); + await root.Raise(); + expect(cb).toHaveBeenCalledWith(); +}); + +test('setting properties on the player should show up on dbus and raise a signal', async () => { + let obj = await bus.getProxyObject('org.mpris.MediaPlayer2.roottest', '/org/mpris/MediaPlayer2'); + let root = obj.getInterface(ROOT_IFACE); + let props = obj.getInterface('org.freedesktop.DBus.Properties'); + let dbusObj = await bus.getProxyObject('org.freedesktop.DBus', '/org/freedesktop/DBus'); + + let ping = async () => { + let peer = dbusObj.getInterface('org.freedesktop.DBus.Peer'); + return peer.Ping(); + }; + + let cb = jest.fn(); + props.on('PropertiesChanged', cb); + + // string array props + let stringArrayProps = [ 'SupportedMimeTypes', 'SupportedUriSchemes' ]; + for (let name of stringArrayProps) { + let playerName = lcFirst(name); + let gotten = await props.Get(ROOT_IFACE, name); + expect(gotten).toEqual(new Variant('as', player[playerName])); + let newValue = ['foo', 'bar']; + player[playerName] = newValue; + await ping(); + let changed = {}; + changed[name] = new Variant('as', newValue); + expect(cb).toHaveBeenLastCalledWith(ROOT_IFACE, changed, []); + } + + // readonly bools + let booleanProps = [ 'CanQuit', 'CanRaise', 'CanSetFullscreen', 'HasTrackList' ]; + for (let name of booleanProps) { + let playerName = lcFirst(name); + let newValue = !player[playerName]; + player[playerName] = newValue; + await ping(); + changed = {}; + changed[name] = new Variant('b', newValue); + expect(cb).toHaveBeenCalledWith(ROOT_IFACE, changed, []); + let gotten = await props.Get(ROOT_IFACE, name); + expect(gotten).toEqual(new Variant('b', newValue)); + } + + // strings + let stringProps = [ 'Identity', 'DesktopEntry' ]; + for (let name of stringProps) { + let playerName = lcFirst(name); + let newValue = 'foo'; + player[playerName] = newValue; + await ping(); + changed = {}; + changed[name] = new Variant('s', newValue); + expect(cb).toHaveBeenCalledWith(ROOT_IFACE, changed, []); + let gotten = await props.Get(ROOT_IFACE, name); + expect(gotten).toEqual(new Variant('s', newValue)); + } + + // fullscreen + let gotten = await props.Get(ROOT_IFACE, 'Fullscreen'); + expect(gotten).toEqual(new Variant('b', player.fullscreen)); + let newValue = !player.fullscreen; + player.fullscreen = newValue; + await ping(); + changed = { + Fullscreen: new Variant('b', newValue) + }; + expect(cb).toHaveBeenLastCalledWith(ROOT_IFACE, changed, []); +});