From 4993dea452ee85f2bd3de576dfa2b88aba6d667e Mon Sep 17 00:00:00 2001 From: Tony Crisci Date: Wed, 7 Nov 2018 17:27:30 -0500 Subject: [PATCH 1/7] Migrate to dbus-next Migration to dbus-next (a fork of dbus-native) is a rewrite, but fixes some outstanding bugs in the project related to variant types. The project is now transpiled to use the experimental decorator feature that will be available in the language at some later time. gulpfile.js contains build instructions. Build with `npm run build`. The dist/ folder contains what will be published on npm. Interfaces are implemented as classes with decorators specifying the properties of the member that is exported on the bus. Update examples and add a new tracklist example. Other bugfixes may have been a side effect of the rewrite. fixes #1 fixes #6 fixes #13 --- .babelrc | 6 + .gitignore | 5 + .npmignore | 0 .tern-project | 5 + examples/player.js | 12 +- examples/playlists.js | 4 +- examples/tracklist.js | 36 ++ gulpfile.js | 19 + index.js | 573 ------------------------------ package.json | 18 +- src/index.js | 196 ++++++++++ src/interfaces/mpris-interface.js | 48 +++ src/interfaces/player.js | 186 ++++++++++ src/interfaces/playlists.js | 76 ++++ src/interfaces/root.js | 98 +++++ src/interfaces/tracklist.js | 84 +++++ src/interfaces/types.js | 70 ++++ 17 files changed, 854 insertions(+), 582 deletions(-) create mode 100644 .babelrc create mode 100644 .npmignore create mode 100644 .tern-project create mode 100644 examples/tracklist.js create mode 100644 gulpfile.js delete mode 100644 index.js create mode 100644 src/index.js create mode 100644 src/interfaces/mpris-interface.js create mode 100644 src/interfaces/player.js create mode 100644 src/interfaces/playlists.js create mode 100644 src/interfaces/root.js create mode 100644 src/interfaces/tracklist.js create mode 100644 src/interfaces/types.js diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..a919466 --- /dev/null +++ b/.babelrc @@ -0,0 +1,6 @@ +{ + "plugins": [ + [ "@babel/plugin-proposal-decorators", { "decoratorsBeforeExport": true, "legacy": false } ], + "@babel/plugin-proposal-class-properties" + ] +} diff --git a/.gitignore b/.gitignore index 59d842b..8b8d236 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ +/dist +/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/package.json b/package.json index 28e56b8..e13ee1f 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,10 @@ "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": "echo \"Error: no test specified\" && exit 1", + "build": "gulp" }, "repository": { "type": "git", @@ -23,6 +24,17 @@ }, "homepage": "https://github.com/emersion/mpris-service", "dependencies": { - "dbus": "^1.0.3" + "dbus-next": "acrisci/node-dbus-next", + "long": "^4.0.0", + "source-map-support": "^0.5.9" + }, + "devDependencies": { + "@babel/core": "^7.1.5", + "@babel/plugin-proposal-class-properties": "^7.1.0", + "@babel/plugin-proposal-decorators": "^7.1.2", + "gulp": "^3.9.1", + "gulp-babel": "^8.0.0", + "gulp-cli": "^2.0.1", + "gulp-sourcemaps": "^2.6.4" } } diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..e3db316 --- /dev/null +++ b/src/index.js @@ -0,0 +1,196 @@ +require('source-map-support').install(); + +const events = require('events'); +const util = require('util'); + +const dbus = require('dbus-next'); +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); +} + +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.init(opts); +} +util.inherits(Player, events.EventEmitter); + +Player.prototype.init = function(opts) { + this.serviceName = `org.mpris.MediaPlayer2.${this.name}`; + let bus = dbus.sessionBus(); + + this.interfaces = {}; + + this._addRootInterface(bus, opts); + + if (this.supportedInterfaces.indexOf('player') >= 0) { + this._addPlayerInterface(bus); + } + if (this.supportedInterfaces.indexOf('trackList') >= 0) { + this._addTracklistInterface(bus); + } + if (this.supportedInterfaces.indexOf('playlists') >= 0) { + this._addPlaylistsInterface(bus); + } +}; + +Player.prototype._addRootInterface = function(bus, opts) { + this.interfaces.root = new RootInterface(this, opts); + this._addEventedPropertiesList(this.interfaces.root, + ['Identity', 'Fullscreen', 'SupportedUriSchemes', 'SupportedMimeTypes']); + 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', 'Rate']; + 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); +} + +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]); + } +}; + +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); +}; + +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; +}; + +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); + }); +}; + +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..9aa6318 --- /dev/null +++ b/src/interfaces/mpris-interface.js @@ -0,0 +1,48 @@ +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) { + this[`_${property}`] = valueDbus; + let changedProperties = {}; + changedProperties[property] = valueDbus; + this.PropertiesChanged(changedProperties); + // nothing is currently settable internally that needs conversion to plain + this.player.emit(property[0].toLowerCase() + property.substr(1), valueDbus); + } + + setProperty(property, valuePlain) { + 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']); + } + + this[`_${property}`] = valueDbus; + let changedProperties = {}; + changedProperties[property] = valueDbus; + this.PropertiesChanged(changedProperties); + } +} + +module.exports = MprisInterface; diff --git a/src/interfaces/player.js b/src/interfaces/player.js new file mode 100644 index 0000000..58dbf9d --- /dev/null +++ b/src/interfaces/player.js @@ -0,0 +1,186 @@ +const dbus = require('dbus-next'); +const MprisInterface = require('./mpris-interface'); +const Variant = dbus.Variant; +const Long = require('long'); + +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) { + console.log(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, + position: 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 Long.fromInt(position); + } +} + +module.exports = PlayerInterface; diff --git a/src/interfaces/playlists.js b/src/interfaces/playlists.js new file mode 100644 index 0000000..0a1e866 --- /dev/null +++ b/src/interfaces/playlists.js @@ -0,0 +1,76 @@ +// 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 []; + } + + return 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 (reverseOrder ? -ret : ret); + }) + .slice(index, maxCount + index) + .map(types.playlistToDbus); + } + + @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 +}; From 476239c8f10234ce0f3dc5ddf1212223f99c2bcc Mon Sep 17 00:00:00 2001 From: Tony Crisci Date: Sun, 3 Mar 2019 13:52:52 -0500 Subject: [PATCH 2/7] update to dbus-next 0.2.0 and add tests --- package.json | 17 ++- src/index.js | 16 ++- src/interfaces/mpris-interface.js | 4 +- src/interfaces/player.js | 9 +- test/player.test.js | 199 ++++++++++++++++++++++++++++++ test/root.test.js | 108 ++++++++++++++++ 6 files changed, 335 insertions(+), 18 deletions(-) create mode 100644 test/player.test.js create mode 100644 test/root.test.js diff --git a/package.json b/package.json index e13ee1f..50ba070 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "Node.js implementation for the MPRIS D-Bus Interface Specification to create a mediaplayer service", "main": "dist/index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "dbus-run-session -- jest", + "coverage": "dbus-run-session -- jest --coverage", "build": "gulp" }, "repository": { @@ -24,17 +25,21 @@ }, "homepage": "https://github.com/emersion/mpris-service", "dependencies": { - "dbus-next": "acrisci/node-dbus-next", - "long": "^4.0.0", + "dbus-next": "0.2.0", "source-map-support": "^0.5.9" }, "devDependencies": { - "@babel/core": "^7.1.5", + "@babel/core": "^7.1.6", "@babel/plugin-proposal-class-properties": "^7.1.0", "@babel/plugin-proposal-decorators": "^7.1.2", - "gulp": "^3.9.1", + "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" + "gulp-sourcemaps": "^2.6.4", + "jest": "^23.6.0", + "jsbi": "^2.0.5", + "regenerator-runtime": "^0.12.1" } } diff --git a/src/index.js b/src/index.js index e3db316..f4bddef 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ 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'); @@ -25,33 +26,36 @@ function Player(opts) { 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}`; - let bus = dbus.sessionBus(); + this._bus = dbus.sessionBus(); this.interfaces = {}; - this._addRootInterface(bus, opts); + this._addRootInterface(this._bus, opts); if (this.supportedInterfaces.indexOf('player') >= 0) { - this._addPlayerInterface(bus); + this._addPlayerInterface(this._bus); } if (this.supportedInterfaces.indexOf('trackList') >= 0) { - this._addTracklistInterface(bus); + this._addTracklistInterface(this._bus); } if (this.supportedInterfaces.indexOf('playlists') >= 0) { - this._addPlaylistsInterface(bus); + 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']); + ['Identity', 'Fullscreen', 'SupportedUriSchemes', 'SupportedMimeTypes', + 'CanQuit', 'CanRaise', 'CanSetFullscreen', 'HasTrackList', + 'DesktopEntry']); bus.export(this.serviceName, MPRIS_PATH, this.interfaces.root); }; diff --git a/src/interfaces/mpris-interface.js b/src/interfaces/mpris-interface.js index 9aa6318..126f3e7 100644 --- a/src/interfaces/mpris-interface.js +++ b/src/interfaces/mpris-interface.js @@ -17,7 +17,7 @@ class MprisInterface extends Interface { this[`_${property}`] = valueDbus; let changedProperties = {}; changedProperties[property] = valueDbus; - this.PropertiesChanged(changedProperties); + Interface.emitPropertiesChanged(this, changedProperties); // nothing is currently settable internally that needs conversion to plain this.player.emit(property[0].toLowerCase() + property.substr(1), valueDbus); } @@ -41,7 +41,7 @@ class MprisInterface extends Interface { this[`_${property}`] = valueDbus; let changedProperties = {}; changedProperties[property] = valueDbus; - this.PropertiesChanged(changedProperties); + Interface.emitPropertiesChanged(this, changedProperties); } } diff --git a/src/interfaces/player.js b/src/interfaces/player.js index 58dbf9d..7d2a440 100644 --- a/src/interfaces/player.js +++ b/src/interfaces/player.js @@ -1,7 +1,7 @@ const dbus = require('dbus-next'); const MprisInterface = require('./mpris-interface'); const Variant = dbus.Variant; -const Long = require('long'); +const JSBI = require('jsbi'); let { property, method, signal, MethodError, @@ -154,7 +154,8 @@ class PlayerInterface extends MprisInterface { @method({inSignature: 'x'}) Seek(offset) { - console.log(offset); + // XXX overflow + offset = JSBI.toNumber(offset); let e = { delta: offset, position: (this.player.position || 0) + offset @@ -166,7 +167,7 @@ class PlayerInterface extends MprisInterface { SetPosition(trackId, position) { let e = { trackId: trackId, - position: position + position: JSBI.toNumber(position) }; this.player.emit('position', e); } @@ -179,7 +180,7 @@ class PlayerInterface extends MprisInterface { @signal({signature: 'x'}) Seeked(position) { - return Long.fromInt(position); + return position; } } diff --git a/test/player.test.js b/test/player.test.js new file mode 100644 index 0000000..94966c5 --- /dev/null +++ b/test/player.test.js @@ -0,0 +1,199 @@ +let dbus = require('dbus-next'); +let Variant = dbus.Variant; +let Player = require('../src'); +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(); + player.once('loopStatus', playerCb); + await props.Set(PLAYER_IFACE, 'LoopStatus', new Variant('s', 'Playlist')); + expect(playerCb).toHaveBeenCalledWith('Playlist'); + 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(); + 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); + } + + // 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') { + // only this property is writable + let playerCb = jest.fn(); + player.once('shuffle', playerCb); + await props.Set(PLAYER_IFACE, name, new Variant('b', !newValue)); + expect(playerCb).toHaveBeenCalledWith(!newValue); + expect(player[playerName]).toEqual(!newValue); + } + } +}); + +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(); + player.once('seek', cb); + await playerIface.Seek(99); + expect(cb).toHaveBeenCalledWith({ delta: 99, position: 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(); + expect(cb).toHaveBeenCalledWith(JSBI.BigInt(99)); +}); diff --git a/test/root.test.js b/test/root.test.js new file mode 100644 index 0000000..8fe87ce --- /dev/null +++ b/test/root.test.js @@ -0,0 +1,108 @@ +let dbus = require('dbus-next'); +let Variant = dbus.Variant; +let Player = require('../src'); + +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 with dbus should set them on the player 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, []); +}); From c06d16c3bad9582da0a1e58039d4d11fd4514ecc Mon Sep 17 00:00:00 2001 From: Tony Crisci Date: Sun, 3 Mar 2019 14:56:10 -0500 Subject: [PATCH 3/7] dont automatically set properties --- package.json | 4 ++-- src/interfaces/mpris-interface.js | 15 +++++++------ src/interfaces/player.js | 1 + test/player.test.js | 35 +++++++++++++++++++++++-------- test/root.test.js | 4 ++-- 5 files changed, 38 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 50ba070..e6cdc5d 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "description": "Node.js implementation for the MPRIS D-Bus Interface Specification to create a mediaplayer service", "main": "dist/index.js", "scripts": { - "test": "dbus-run-session -- jest", - "coverage": "dbus-run-session -- jest --coverage", + "test": "gulp && dbus-run-session -- jest", + "coverage": "gulp && dbus-run-session -- jest --coverage", "build": "gulp" }, "repository": { diff --git a/src/interfaces/mpris-interface.js b/src/interfaces/mpris-interface.js index 126f3e7..32d0fda 100644 --- a/src/interfaces/mpris-interface.js +++ b/src/interfaces/mpris-interface.js @@ -14,15 +14,12 @@ class MprisInterface extends Interface { } _setPropertyInternal(property, valueDbus) { - this[`_${property}`] = valueDbus; - let changedProperties = {}; - changedProperties[property] = valueDbus; - Interface.emitPropertiesChanged(this, changedProperties); // 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') { @@ -38,10 +35,12 @@ class MprisInterface extends Interface { valuePlain.filter((t) => t['mpris:trackid']).map((t) => t['mpris:trackid']); } - this[`_${property}`] = valueDbus; - let changedProperties = {}; - changedProperties[property] = valueDbus; - Interface.emitPropertiesChanged(this, changedProperties); + if (this[`_${property}`] !== valueDbus) { + this[`_${property}`] = valueDbus; + let changedProperties = {}; + changedProperties[property] = valueDbus; + Interface.emitPropertiesChanged(this, changedProperties); + } } } diff --git a/src/interfaces/player.js b/src/interfaces/player.js index 7d2a440..75c4716 100644 --- a/src/interfaces/player.js +++ b/src/interfaces/player.js @@ -167,6 +167,7 @@ class PlayerInterface extends MprisInterface { SetPosition(trackId, position) { let e = { trackId: trackId, + // XXX overflow position: JSBI.toNumber(position) }; this.player.emit('position', e); diff --git a/test/player.test.js b/test/player.test.js index 94966c5..1330739 100644 --- a/test/player.test.js +++ b/test/player.test.js @@ -1,6 +1,6 @@ let dbus = require('dbus-next'); let Variant = dbus.Variant; -let Player = require('../src'); +let Player = require('../dist'); let JSBI = require('jsbi'); const ROOT_IFACE = 'org.mpris.MediaPlayer2'; @@ -115,10 +115,16 @@ test('getting and setting properties on the player and on the interface should w expect(cb).toHaveBeenLastCalledWith(PLAYER_IFACE, changed, []); gotten = await props.Get(PLAYER_IFACE, 'LoopStatus'); expect(gotten).toEqual(new Variant('s', 'Track')); - let playerCb = jest.fn(); + 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 @@ -132,11 +138,15 @@ test('getting and setting properties on the player and on the interface should w 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(); + 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 @@ -153,12 +163,15 @@ test('getting and setting properties on the player and on the interface should w 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(); + let playerCb = jest.fn(val => { + player.shuffle = val; + }); player.once('shuffle', playerCb); - await props.Set(PLAYER_IFACE, name, new Variant('b', !newValue)); - expect(playerCb).toHaveBeenCalledWith(!newValue); - expect(player[playerName]).toEqual(!newValue); + await props.Set(PLAYER_IFACE, name, new Variant('b', nextNewValue)); + expect(playerCb).toHaveBeenCalledWith(nextNewValue); + expect(player[playerName]).toEqual(nextNewValue); } } }); @@ -180,10 +193,13 @@ test('position specific properties, methods, and signals should work', async () expect(position).toEqual(new Variant('x', JSBI.BigInt(0))); // Seek - let cb = jest.fn(); + 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(); @@ -195,5 +211,6 @@ test('position specific properties, methods, and signals should work', async () playerIface.once('Seeked', cb); player.seeked(99); await ping(); - expect(cb).toHaveBeenCalledWith(JSBI.BigInt(99)); + // this one updates position + expect(cb).toHaveBeenCalledWith(JSBI.BigInt(198)); }); diff --git a/test/root.test.js b/test/root.test.js index 8fe87ce..811f0c9 100644 --- a/test/root.test.js +++ b/test/root.test.js @@ -1,6 +1,6 @@ let dbus = require('dbus-next'); let Variant = dbus.Variant; -let Player = require('../src'); +let Player = require('../dist'); const ROOT_IFACE = 'org.mpris.MediaPlayer2'; const PLAYER_IFACE = 'org.mpris.MediaPlayer2.Player'; @@ -39,7 +39,7 @@ test('calling methods should raise a signal on the player', async () => { expect(cb).toHaveBeenCalledWith(); }); -test('setting properties with dbus should set them on the player and raise a signal', async () => { +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'); From af2d4104e3a3cc5e1ea8cd0284b7acbeac0214fe Mon Sep 17 00:00:00 2001 From: Tony Crisci Date: Sun, 3 Mar 2019 16:29:34 -0500 Subject: [PATCH 4/7] add tests for playlists interface --- src/interfaces/playlists.js | 10 +- test/playlists.test.js | 184 ++++++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 test/playlists.test.js diff --git a/src/interfaces/playlists.js b/src/interfaces/playlists.js index 0a1e866..2b30fe4 100644 --- a/src/interfaces/playlists.js +++ b/src/interfaces/playlists.js @@ -49,7 +49,7 @@ class PlaylistsInterface extends MprisInterface { return []; } - return this.player.playlists.sort(function(a, b) { + let result = this.player.playlists.sort(function(a, b) { let ret = 1; switch (order) { case 'Alphabetical': @@ -61,10 +61,16 @@ class PlaylistsInterface extends MprisInterface { case 'UserDefined': break; } - return (reverseOrder ? -ret : ret); + return ret; }) .slice(index, maxCount + index) .map(types.playlistToDbus); + + if (reverseOrder) { + result.reverse(); + } + + return result; } @signal({signature: '(oss)'}) 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); +}); From 0191e9b67f03f1e67e31256e50ccf742e7cbf01e Mon Sep 17 00:00:00 2001 From: Tony Crisci Date: Sun, 3 Mar 2019 16:33:10 -0500 Subject: [PATCH 5/7] bump to dbus-next 0.2.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e6cdc5d..ab4a218 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ }, "homepage": "https://github.com/emersion/mpris-service", "dependencies": { - "dbus-next": "0.2.0", + "dbus-next": "0.2.1", "source-map-support": "^0.5.9" }, "devDependencies": { From 24f6d005db164efc3467cd3b4bbacdaef6d0818b Mon Sep 17 00:00:00 2001 From: Tony Crisci Date: Sun, 3 Mar 2019 16:37:06 -0500 Subject: [PATCH 6/7] set node engine at 6.3.0 --- .babelrc | 3 ++- package.json | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.babelrc b/.babelrc index a919466..d1673a3 100644 --- a/.babelrc +++ b/.babelrc @@ -1,6 +1,7 @@ { "plugins": [ [ "@babel/plugin-proposal-decorators", { "decoratorsBeforeExport": true, "legacy": false } ], - "@babel/plugin-proposal-class-properties" + "@babel/plugin-proposal-class-properties", + "@babel/plugin-transform-async-to-generator" ] } diff --git a/package.json b/package.json index ab4a218..f0a6eab 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@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", @@ -41,5 +42,8 @@ "jest": "^23.6.0", "jsbi": "^2.0.5", "regenerator-runtime": "^0.12.1" + }, + "engine": { + "node": ">=6.3.0" } } From 3750ddf37b9ca894f5659f1358ce6cef0bad6aa6 Mon Sep 17 00:00:00 2001 From: Tony Crisci Date: Sun, 3 Mar 2019 19:54:13 -0500 Subject: [PATCH 7/7] add documentation --- .gitignore | 1 + jsdoc.conf | 3 ++ package.json | 4 +- src/index.js | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 jsdoc.conf diff --git a/.gitignore b/.gitignore index 8b8d236..219fd48 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /dist +/doc /package-lock.json /yarn.lock *.swp 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 f0a6eab..38721db 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "test": "gulp && dbus-run-session -- jest", "coverage": "gulp && dbus-run-session -- jest --coverage", - "build": "gulp" + "build": "gulp", + "doc": "jsdoc --verbose -c jsdoc.conf --readme README.md --package package.json src/index.js -d doc" }, "repository": { "type": "git", @@ -41,6 +42,7 @@ "gulp-sourcemaps": "^2.6.4", "jest": "^23.6.0", "jsbi": "^2.0.5", + "jsdoc": "^3.5.5", "regenerator-runtime": "^0.12.1" }, "engine": { diff --git a/src/index.js b/src/index.js index f4bddef..51f2464 100644 --- a/src/index.js +++ b/src/index.js @@ -17,6 +17,66 @@ 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); @@ -63,7 +123,7 @@ 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', 'Rate']; + 'CanGoNext', 'CanGoPrevious', 'MinimumRate', 'MaximumRate']; this._addEventedPropertiesList(this.interfaces.player, eventedProps); bus.export(this.serviceName, MPRIS_PATH, this.interfaces.player); }; @@ -94,6 +154,15 @@ Player.prototype._addPlaylistsInterface = function(bus) { 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) { @@ -131,6 +200,14 @@ Player.prototype._addEventedPropertiesList = function(iface, props) { } }; +/** + * 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); @@ -140,7 +217,7 @@ Player.prototype.getTrackIndex = function(trackId) { for (let i = 0; i < this.tracks.length; i++) { let track = this.tracks[i]; - if (track['mpris:trackid'] == trackId) { + if (track['mpris:trackid'] === trackId) { return i; } } @@ -171,6 +248,14 @@ Player.prototype.removeTrack = function(trackId) { 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]; @@ -183,6 +268,14 @@ Player.prototype.getPlaylistIndex = function(playlistId) { 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; @@ -193,6 +286,14 @@ Player.prototype.setPlaylists = function(playlists) { }); }; +/** + * 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); };