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 +};