« get me outta code hell

mtui - Music Text User Interface - user-friendly command line music player
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--backend.js26
-rw-r--r--general-util.js11
-rw-r--r--socket.js157
-rw-r--r--todo.txt17
-rw-r--r--ui.js39
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()