« get me outta code hell

synchronize shared sources on join + other stuff - mtui - Music Text User Interface - user-friendly command line music player
about summary refs log tree commit diff
diff options
context:
space:
mode:
author(quasar) nebula <towerofnix@gmail.com>2021-04-26 13:15:09 -0300
committer(quasar) nebula <qznebula@protonmail.com>2024-05-16 22:29:25 -0300
commitebbdaa3473b4885468eb27922e24511c93b962ca (patch)
treeb9e203691a5089edd48fb8b9eadc6a0add38e904
parent4bee8ad4fc1c315055d0d3377f8140b02cd51621 (diff)
synchronize shared sources on join + other stuff
-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 39f2225..ed89133 100644
--- a/backend.js
+++ b/backend.js
@@ -702,6 +702,11 @@ export default 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)
@@ -903,15 +908,24 @@ export default 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)
-  }
 }
diff --git a/general-util.js b/general-util.js
index 4bfd491..364da88 100644
--- a/general-util.js
+++ b/general-util.js
@@ -349,3 +349,14 @@ export async function silenceEvents(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
+export function parseWithoutPrototype(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 e02285c..c91a1af 100644
--- a/socket.js
+++ b/socket.js
@@ -34,6 +34,7 @@ import {
 
 import {
   getTimeStringsFromSec,
+  parseWithoutPrototype,
   silenceEvents,
 } from './general-util.js'
 
@@ -60,7 +61,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 => {
@@ -74,9 +75,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) {
@@ -87,7 +95,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) {
@@ -137,8 +149,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'
       }
@@ -283,23 +302,36 @@ export 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]
       }
     })
 
@@ -315,7 +347,7 @@ export function makeSocketServer() {
 
       command.sender = 'client'
       command.senderSocketId = socketId
-      command.senderNickname = nickname
+      command.senderNickname = socketInfo.nickname
 
       if (!validateCommand(command)) {
         return
@@ -324,7 +356,7 @@ export 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'
@@ -407,22 +439,30 @@ export 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
       }
 
@@ -451,8 +491,9 @@ export function makeSocketServer() {
 
     socket.write(serializeCommandToData({
       sender: 'server',
-      code: 'initialize-backend',
-      backend: savedBackend
+      code: 'initialize-party',
+      backend: savedBackend,
+      socketInfo: socketInfoMap
     }) + '\n')
   })
 
@@ -502,16 +543,16 @@ export 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)
@@ -549,7 +590,7 @@ export 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)}`
@@ -640,10 +681,31 @@ export 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', () => {
@@ -661,13 +723,16 @@ export 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':
@@ -726,11 +791,10 @@ export 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': {
@@ -748,11 +812,10 @@ export 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':
@@ -854,7 +917,7 @@ export 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})
   })
@@ -891,15 +954,15 @@ export 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 2cf7f66..e54d532 100644
--- a/todo.txt
+++ b/todo.txt
@@ -703,6 +703,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,
@@ -711,6 +712,22 @@ TODO: We currently use a hack to access the original item in the context menu
       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!)
+
 TODO: Pressing escape while you've got items selected should deselect those
       items, rather than stop playback! ...Or SHOULD IT??? Well, yes. But it's
       still handy to not be locked out of stopping playback altogether.
diff --git a/ui.js b/ui.js
index 7e36858..371da80 100644
--- a/ui.js
+++ b/ui.js
@@ -264,8 +264,6 @@ export default 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)
@@ -285,6 +283,11 @@ export default 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)
 
@@ -481,8 +484,8 @@ export default class AppElement extends FocusElement {
       'handleRemovedQueuePlayer',
       'handleSetLoopQueueAtEnd',
       'handleLogMessage',
-      'handleGotPartyGrouplike',
-      'handlePartyGrouplikeUpdated'
+      'handleGotSharedSources',
+      'handleSharedSourcesUpdated'
     ]) {
       this[key] = this[key].bind(this)
     }
@@ -555,8 +558,8 @@ export default class AppElement extends FocusElement {
     this.backend.on('removed queue player', this.handleRemovedQueuePlayer)
     this.backend.on('set-loop-queue-at-end', this.handleSetLoopQueueAtEnd)
     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)
   }
 
   removeBackendListeners() {
@@ -565,8 +568,8 @@ export default class AppElement extends FocusElement {
     this.backend.removeListener('removed queue player', this.handleRemovedQueuePlayer)
     this.backend.removeListener('set-loop-queue-at-end', this.handleSetLoopQueueAtEnd)
     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)
   }
 
   handleAddedQueuePlayer(queuePlayer) {
@@ -588,16 +591,21 @@ export default 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()
   }
 
   async handlePlayingDetails(track, oldTrack, startTime, queuePlayer) {
@@ -1093,7 +1101,11 @@ export default class AppElement extends FocusElement {
   }
 
   emitMarkChanged() {
+    this.clearCachedMarkStatuses()
     this.emit('mark changed')
+  }
+
+  clearCachedMarkStatuses() {
     this.cachedMarkStatuses = new Map()
     this.scheduleDrawWithoutPropertyChange()
   }
@@ -1961,11 +1973,6 @@ export default 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()