diff options
-rw-r--r-- | backend.js | 26 | ||||
-rw-r--r-- | general-util.js | 11 | ||||
-rw-r--r-- | socket.js | 157 | ||||
-rw-r--r-- | todo.txt | 17 | ||||
-rw-r--r-- | ui.js | 39 |
5 files changed, 181 insertions, 69 deletions
diff --git a/backend.js b/backend.js index 3d9c386..0f15a43 100644 --- a/backend.js +++ b/backend.js @@ -672,6 +672,11 @@ class Backend extends EventEmitter { this.waitWhenDonePlaying = false this.hasAnnouncedJoin = false + this.sharedSourcesMap = Object.create(null) + this.sharedSourcesGrouplike = { + name: 'Shared Sources', + items: [] + } this.recordStore = new RecordStore() this.throttleMetadata = throttlePromise(10) @@ -873,17 +878,26 @@ class Backend extends EventEmitter { this.hasAnnouncedJoin = hasAnnouncedJoin } - loadPartyGrouplike(socketId, partyGrouplike) { - this.emit('got party grouplike', socketId, partyGrouplike) + loadSharedSources(socketId, sharedSources) { + if (socketId in this.sharedSourcesMap) { + return + } + + this.sharedSourcesMap[socketId] = sharedSources + + sharedSources[parentSymbol] = this.sharedSourcesGrouplike + this.sharedSourcesGrouplike.items.push(sharedSources) + + this.emit('got shared sources', socketId, sharedSources) + } + + sharedSourcesUpdated(socketId, sharedSources) { + this.emit('shared sources updated', socketId, sharedSources) } shareWithParty(item) { this.emit('share with party', item) } - - partyGrouplikeUpdated(socketId, partyGrouplike) { - this.emit('party grouplike updated', socketId, partyGrouplike) - } } module.exports = Backend diff --git a/general-util.js b/general-util.js index 0f5bdd5..85ff8e5 100644 --- a/general-util.js +++ b/general-util.js @@ -324,3 +324,14 @@ module.exports.silenceEvents = async function(emitter, eventsToSilence, callback emitter.emit = oldEmit } + +// Kindly stolen from ESDiscuss: +// https://esdiscuss.org/topic/proposal-add-an-option-to-omit-prototype-of-objects-created-by-json-parse#content-1 +module.exports.parseWithoutPrototype = function(string) { + return JSON.parse(string, function(k, v) { + if (v && typeof v === 'object' && !Array.isArray(v)) { + return Object.assign(Object.create(null), v) + } + return v + }) +} diff --git a/socket.js b/socket.js index bc35c76..a40dc97 100644 --- a/socket.js +++ b/socket.js @@ -40,6 +40,7 @@ const { const { getTimeStringsFromSec, + parseWithoutPrototype, silenceEvents } = require('./general-util') @@ -67,7 +68,7 @@ function serializePartySource(item) { } } -function deserializePartySource(source) { +function deserializePartySource(source, parent = null) { // Reconstruct a party source into the ordinary group/track format. const recursive = source => { @@ -81,9 +82,16 @@ function deserializePartySource(source) { } const top = recursive(source) - return (isGroup(top) + + const item = (isGroup(top) ? updateGroupFormat(top) : updateTrackFormat(top)) + + if (parent) { + item[parentSymbol] = parent + } + + return item } function serializeCommandToData(command) { @@ -94,7 +102,11 @@ function serializeCommandToData(command) { function deserializeDataToCommand(data) { // Turn data received from a socket into a command that can be processed as // an action to apply to the mtui backend. - return JSON.parse(data) + return parseWithoutPrototype(data) +} + +function namePartySources(nickname) { + return `Party Sources - ${nickname}` } function isItemRef(ref) { @@ -144,8 +156,15 @@ function validateCommand(command) { switch (command.sender) { case 'server': switch (command.code) { - case 'initialize-backend': - return typeof command.backend === 'object' + case 'initialize-party': + return ( + typeof command.backend === 'object' && + typeof command.socketInfo === 'object' && + Object.values(command.socketInfo).every(info => ( + typeof info.nickname === 'string' && + Array.isArray(info.sharedSources) + )) + ) case 'set-socket-id': return typeof command.socketId === 'string' } @@ -290,23 +309,36 @@ function makeSocketServer() { const server = new net.Server() const socketMap = Object.create(null) + // Keeps track of details to share with newly joining sockets for + // synchronization. + const socketInfoMap = Object.create(null) + server.canonicalBackend = null // <variable> -> queue player id -> array: socket - const readyToResume = {} - const donePlaying = {} + const readyToResume = Object.create(null) + const donePlaying = Object.create(null) server.on('connection', socket => { const socketId = shortid.generate() - socketMap[socketId] = socket + const socketInfo = { + hasAnnouncedJoin: false, + nickname: DEFAULT_NICKNAME, - let hasAnnouncedJoin = false - let nickname = DEFAULT_NICKNAME + // Unlike in client code, this isn't an array of actual playlist items; + // rather, it's the intermediary format used when transferring between + // client and server. + sharedSources: [] + } + + socketMap[socketId] = socket + socketInfoMap[socketId] = socketInfo socket.on('close', () => { if (socketId in socketMap) { delete socketMap[socketId] + delete socketInfoMap[socketId] } }) @@ -322,7 +354,7 @@ function makeSocketServer() { command.sender = 'client' command.senderSocketId = socketId - command.senderNickname = nickname + command.senderNickname = socketInfo.nickname if (!validateCommand(command)) { return @@ -331,7 +363,7 @@ function makeSocketServer() { // If the socket hasn't announced its joining yet, it only has access to // a few commands. - if (!hasAnnouncedJoin) { + if (!socketInfo.hasAnnouncedJoin) { if (![ 'announce-join', 'set-nickname' @@ -414,22 +446,30 @@ function makeSocketServer() { // Also attach the old nickname for display in log messages. if (command.code === 'set-nickname') { - command.oldNickname = nickname - command.senderNickname = nickname - nickname = command.nickname + command.oldNickname = socketInfo.nickname + command.senderNickname = socketInfo.nickname + socketInfo.nickname = command.nickname + } + + // If it's a 'share-with-party' command, keep track of the item being + // shared, so we can synchronize newly joining sockets with it. + + if (command.code === 'share-with-party') { + const { sharedSources } = socketInfoMap[socketId] + sharedSources.push(command.item) } // If it's an 'announce-join' command, mark the variable for this! if (command.code === 'announce-join') { - hasAnnouncedJoin = true; + socketInfo.hasAnnouncedJoin = true; } // If the socket hasn't announced its joining yet, don't relay the // command. (Since hasAnnouncedJoin gets set above, 'announce-join' - // will meet this condition.) + // will pass this condition.) - if (!hasAnnouncedJoin) { + if (!socketInfo.hasAnnouncedJoin) { return } @@ -458,8 +498,9 @@ function makeSocketServer() { socket.write(serializeCommandToData({ sender: 'server', - code: 'initialize-backend', - backend: savedBackend + code: 'initialize-party', + backend: savedBackend, + socketInfo: socketInfoMap }) + '\n') }) @@ -509,16 +550,16 @@ function attachBackendToSocketClient(backend, client) { let hasAnnouncedJoin = false - const partyGrouplike = { - name: `Party Sources - ${client.nickname}`, + const sharedSources = { + name: namePartySources(client.nickname), isPartySources: true, items: [] } - const partyGrouplikeMap = Object.create(null) + const socketInfoMap = Object.create(null) const getPlaylistSources = () => - partyGrouplike.items.map(item => item[originalSymbol]) + sharedSources.items.map(item => item[originalSymbol]) backend.setHasAnnouncedJoin(false) backend.setAlwaysStartPaused(true) @@ -556,7 +597,7 @@ function attachBackendToSocketClient(backend, client) { case 'distribute-queue': actionmsg = `distributed ${itemToMessage(command.topItem)} across the queue ${command.opts.how}` break - case 'initialize-backend': + case 'initialize-party': return case 'play': actionmsg = `started playing ${itemToMessage(command.track)}` @@ -647,10 +688,31 @@ function attachBackendToSocketClient(backend, client) { switch (command.code) { case 'set-socket-id': client.socketId = command.socketId - partyGrouplikeMap[command.socketId] = partyGrouplike - backend.loadPartyGrouplike(client.socketId, partyGrouplike) + socketInfoMap[command.socketId] = { + nickname: client.nickname, + sharedSources + } + backend.loadSharedSources(command.socketId, sharedSources) return - case 'initialize-backend': + case 'initialize-party': + for (const [ socketId, info ] of Object.entries(command.socketInfo)) { + const nickname = info.nickname + + const sharedSources = { + name: namePartySources(nickname), + isPartySources: true + } + + sharedSources.items = info.sharedSources.map( + item => deserializePartySource(item, sharedSources)) + + socketInfoMap[socketId] = { + nickname, + sharedSources + } + + backend.loadSharedSources(socketId, sharedSources) + } await restoreBackend(backend, command.backend) backend.on('playing', QP => { QP.once('received time data', () => { @@ -668,13 +730,16 @@ function attachBackendToSocketClient(backend, client) { switch (command.code) { case 'announce-join': { - const partyGrouplike = { - name: `Party Sources - ${command.senderNickname}`, + const sharedSources = { + name: namePartySources(command.senderNickname), isPartySources: true, items: [] } - partyGrouplikeMap[command.senderSocketId] = partyGrouplike - backend.loadPartyGrouplike(command.senderSocketId, partyGrouplike) + socketInfoMap[command.senderSocketId] = { + nickname: command.senderNickname, + sharedSources + } + backend.loadSharedSources(command.senderSocketId, sharedSources) return } case 'clear-queue': @@ -733,11 +798,10 @@ function attachBackendToSocketClient(backend, client) { if (QP) silenceEvents(QP, ['seek-to'], () => QP.seekTo(command.time)) return case 'set-nickname': { - const partyGrouplike = partyGrouplikeMap[command.senderSocketId] - if (partyGrouplike) { - partyGrouplike.name = `Party Sources - ${command.senderNickname}` - backend.partyGrouplikeUpdated(client.socketId, partyGrouplike) - } + const info = socketInfoMap[command.senderSocketId] + info.nickname = command.senderNickname + info.sharedSources.name = namePartySources(command.senderNickname) + backend.sharedSourcesUpdated(client.socketId, info.sharedSources) return } case 'set-pause': { @@ -755,11 +819,10 @@ function attachBackendToSocketClient(backend, client) { return } case 'share-with-party': { - const deserialized = deserializePartySource(command.item) - const partyGrouplike = partyGrouplikeMap[command.senderSocketId] - deserialized[parentSymbol] = partyGrouplike - partyGrouplike.items.push(deserialized) - backend.partyGrouplikeUpdated(command.senderSocketId, partyGrouplike) + const { sharedSources } = socketInfoMap[command.senderSocketId] + const deserialized = deserializePartySource(command.item, sharedSources) + sharedSources.items.push(deserialized) + backend.sharedSourcesUpdated(command.senderSocketId, sharedSources) return } case 'stop-playing': @@ -861,7 +924,7 @@ function attachBackendToSocketClient(backend, client) { backend.on('set party nickname', nickname => { let oldNickname = client.nickname - partyGrouplike.name = `Party Sources - ${nickname}` + sharedSources.name = namePartySources(nickname) client.nickname = nickname client.sendCommand({code: 'set-nickname', nickname, oldNickname}) }) @@ -898,15 +961,15 @@ function attachBackendToSocketClient(backend, client) { }) backend.on('share with party', item => { - if (partyGrouplike.items.every(x => x[originalSymbol] !== item)) { + if (sharedSources.items.every(x => x[originalSymbol] !== item)) { const serialized = serializePartySource(item) const deserialized = deserializePartySource(serialized) - deserialized[parentSymbol] = partyGrouplike + deserialized[parentSymbol] = sharedSources deserialized[originalSymbol] = item - partyGrouplike.items.push(deserialized) - backend.partyGrouplikeUpdated(client.socketId, partyGrouplike) + sharedSources.items.push(deserialized) + backend.sharedSourcesUpdated(client.socketId, sharedSources) updateRestoredTracksUsingPlaylists(backend, getPlaylistSources()) diff --git a/todo.txt b/todo.txt index 726e04f..dab8e29 100644 --- a/todo.txt +++ b/todo.txt @@ -592,6 +592,7 @@ TODO: The checks for "grouplike"/"track" have been super arbitrary for a long TODO: Synchronize items that have been shared with the party upon a new client joining. Should be next to (or part of) the initialize-backend command. + (Done!) TODO: We currently use a hack to access the original item in the context menu for items in the party sources listing. This doesn't make, for example, @@ -599,3 +600,19 @@ TODO: We currently use a hack to access the original item in the context menu to specifically refer to the item "represented" by a line, rather than the literal object it's associated with (i.e. the pseudo-track/group shared in the sources array). + +TODO: Broadcast when a socket disconnects; show a log message and remove their + shared sources from the UI of other clients. + +TODO: Ditto for the server! Not (exclusively) as a broadcast message, though - + detect if the connection to the server is lost for any reason. + +TODO: The validation code for share-with-party sucks! It should be made into a + separate function which runs recursively, and should be used to validate + initialize-party too. + +TODO: Show debug log messages when validating a command fails! On both server + and client end. + +TODO: Naming a shared sources list should definitely happen in a function. + (Done!) diff --git a/ui.js b/ui.js index bd73bdc..da6cff9 100644 --- a/ui.js +++ b/ui.js @@ -273,8 +273,6 @@ class AppElement extends FocusElement { this.metadataStatusLabel.visible = false this.tabberPane.addChild(this.metadataStatusLabel) - this.newGrouplikeListing() - this.queueListingElement = new QueueListingElement(this) this.setupCommonGrouplikeListingEvents(this.queueListingElement) this.queuePane.addChild(this.queueListingElement) @@ -294,6 +292,11 @@ class AppElement extends FocusElement { this.queueListingElement.on('select main listing', () => this.selected()) + if (this.config.showPartyControls) { + const sharedSourcesListing = this.newGrouplikeListing() + sharedSourcesListing.loadGrouplike(this.backend.sharedSourcesGrouplike) + } + this.playbackPane = new Pane() this.addChild(this.playbackPane) @@ -461,8 +464,8 @@ class AppElement extends FocusElement { 'handleAddedQueuePlayer', 'handleRemovedQueuePlayer', 'handleLogMessage', - 'handleGotPartyGrouplike', - 'handlePartyGrouplikeUpdated', + 'handleGotSharedSources', + 'handleSharedSourcesUpdated', 'handleSetLoopQueueAtEnd' ]) { this[key] = this[key].bind(this) @@ -535,8 +538,8 @@ class AppElement extends FocusElement { this.backend.on('added queue player', this.handleAddedQueuePlayer) this.backend.on('removed queue player', this.handleRemovedQueuePlayer) this.backend.on('log message', this.handleLogMessage) - this.backend.on('got party grouplike', this.handleGotPartyGrouplike) - this.backend.on('party grouplike updated', this.handlePartyGrouplikeUpdated) + this.backend.on('got shared sources', this.handleGotSharedSources) + this.backend.on('shared sources updated', this.handleSharedSourcesUpdated) this.backend.on('set-loop-queue-at-end', this.handleSetLoopQueueAtEnd) } @@ -545,8 +548,8 @@ class AppElement extends FocusElement { this.backend.removeListener('added queue player', this.handleAddedQueuePlayer) this.backend.removeListener('removed queue player', this.handleRemovedQueuePlayer) this.backend.removeListener('log message', this.handleLogMessage) - this.backend.removeListener('got party grouplike', this.handleGotPartyGrouplike) - this.backend.removeListener('party grouplike updated', this.handlePartyGrouplikeUpdated) + this.backend.removeListener('got shared sources', this.handleGotSharedSources) + this.backend.removeListener('shared sources updated', this.handleSharedSourcesUpdated) this.backend.removeListener('set-loop-queue-at-end', this.handleSetLoopQueueAtEnd) } @@ -565,16 +568,21 @@ class AppElement extends FocusElement { this.log.newLogMessage(messageInfo) } - handleGotPartyGrouplike(socketId, partyGrouplike) { - this.newPartyTab(socketId, partyGrouplike) + handleGotSharedSources(socketId, sharedSources) { + for (const grouplikeListing of this.tabber.tabberElements) { + if (grouplikeListing.grouplike === this.backend.sharedSourcesGrouplike) { + grouplikeListing.loadGrouplike(this.backend.sharedSourcesGrouplike, false) + } + } } - handlePartyGrouplikeUpdated(socketId, partyGrouplike) { + handleSharedSourcesUpdated(socketId, partyGrouplike) { for (const grouplikeListing of this.tabber.tabberElements) { if (grouplikeListing.grouplike === partyGrouplike) { grouplikeListing.loadGrouplike(partyGrouplike, false) } } + this.clearCachedMarkStatuses() } handleSetLoopQueueAtEnd() { @@ -979,7 +987,11 @@ class AppElement extends FocusElement { } emitMarkChanged() { + this.clearCachedMarkStatuses() this.emit('mark changed') + } + + clearCachedMarkStatuses() { this.cachedMarkStatuses = new Map() this.scheduleDrawWithoutPropertyChange() } @@ -1593,11 +1605,6 @@ class AppElement extends FocusElement { }) } - newPartyTab(socketId, partyGrouplike) { - const listing = this.newGrouplikeListing() - listing.loadGrouplike(partyGrouplike) - } - cloneCurrentTab() { const grouplike = this.tabber.currentElement.grouplike const listing = this.newGrouplikeListing() |