« get me outta code hell

support queue controls over socket clients - mtui - Music Text User Interface - user-friendly command line music player
about summary refs log tree commit diff
diff options
context:
space:
mode:
authorFlorrie <towerofnix@gmail.com>2020-07-11 16:22:01 -0300
committer(quasar) nebula <qznebula@protonmail.com>2024-05-16 18:55:01 -0300
commit2c7e3c8fb279f20da3d1b4f5610e65dc43a22ac2 (patch)
tree39a3aee1c9611141ac38487e06659002e57c124b
parente6698a135099348b62d295e904580f4341447958 (diff)
support queue controls over socket clients
-rw-r--r--backend.js22
-rw-r--r--general-util.js14
-rw-r--r--playlist-utils.js128
-rw-r--r--serialized-backend.js107
-rw-r--r--socket.js206
5 files changed, 380 insertions, 97 deletions
diff --git a/backend.js b/backend.js
index 418c2eb..c59dfdf 100644
--- a/backend.js
+++ b/backend.js
@@ -165,6 +165,7 @@ class QueuePlayer extends EventEmitter {
     }
 
     recursivelyAddTracks(topItem)
+    this.emit('queue', topItem, afterItem, {movePlayingTrack})
     this.emitQueueUpdated()
 
     // This is the first new track, if a group was queued.
@@ -173,9 +174,12 @@ class QueuePlayer extends EventEmitter {
     return newTrack
   }
 
-  distributeQueue(grouplike, {how = 'evenly', rangeEnd = 'end-of-queue'}) {
-    if (isTrack(grouplike)) {
-      grouplike = {items: [grouplike]}
+  distributeQueue(topItem, {how = 'evenly', rangeEnd = 'end-of-queue'} = {}) {
+    let grouplike
+    if (isTrack(topItem)) {
+      grouplike = {items: [topItem]}
+    } else {
+      grouplike = topItem
     }
 
     const { items } = this.queueGrouplike
@@ -227,6 +231,7 @@ class QueuePlayer extends EventEmitter {
       }
     }
 
+    this.emit('distribute-queue', topItem, {how, rangeEnd})
     this.emitQueueUpdated()
   }
 
@@ -271,6 +276,7 @@ class QueuePlayer extends EventEmitter {
     }
 
     recursivelyUnqueueTracks(topItem)
+    this.emit('unqueue', topItem)
     this.emitQueueUpdated()
 
     return focusItem
@@ -288,6 +294,7 @@ class QueuePlayer extends EventEmitter {
       items.splice(index)
     }
 
+    this.emit('clear-queue-past', track)
     this.emitQueueUpdated()
   }
 
@@ -304,6 +311,7 @@ class QueuePlayer extends EventEmitter {
       items.splice(startIndex, endIndex - startIndex)
     }
 
+    this.emit('clear-queue-up-to', track)
     this.emitQueueUpdated()
   }
 
@@ -336,6 +344,7 @@ class QueuePlayer extends EventEmitter {
     const remainingItems = queue.items.slice(index)
     const newItems = initialItems.concat(shuffleArray(remainingItems))
     queue.items = newItems
+    this.emit('shuffle-queue')
     this.emitQueueUpdated()
   }
 
@@ -344,6 +353,7 @@ class QueuePlayer extends EventEmitter {
     // the track that's currently playing).
     this.queueGrouplike.items = this.queueGrouplike.items
       .filter(item => item === this.playingTrack)
+    this.emit('clear-queue')
     this.emitQueueUpdated()
   }
 
@@ -563,7 +573,11 @@ class QueuePlayer extends EventEmitter {
   }
 
   seekBack(seconds) {
-    this.time -= seconds
+    if (this.time < seconds) {
+      this.time = 0
+    } else {
+      this.time -= seconds
+    }
     this.player.seekBack(seconds)
     this.emit('seek-back', +seconds)
   }
diff --git a/general-util.js b/general-util.js
index b767a1b..b4491de 100644
--- a/general-util.js
+++ b/general-util.js
@@ -335,3 +335,17 @@ export async function parseOptions(options, optionDescriptorMap) {
 }
 
 parseOptions.handleDashless = Symbol()
+
+export function silenceEvents(emitter, eventsToSilence, callback) {
+  const oldEmit = emitter.emit
+
+  emitter.emit = function(event, ...data) {
+    if (!eventsToSilence.includes(event)) {
+      oldEmit.apply(emitter, [event, ...data])
+    }
+  }
+
+  callback()
+
+  emitter.emit = oldEmit
+}
diff --git a/playlist-utils.js b/playlist-utils.js
index f58a9e8..7c742ae 100644
--- a/playlist-utils.js
+++ b/playlist-utils.js
@@ -187,15 +187,30 @@ export function flattenGrouplike(grouplike) {
   // levels in the group tree and returns them as a new group containing those
   // tracks.
 
-  return {
-    items: grouplike.items.map(item => {
-      if (isGroup(item)) {
-        return flattenGrouplike(item).items
-      } else {
-        return [item]
-      }
-    }).reduce((a, b) => a.concat(b), [])
-  }
+  return {items: getFlatTrackList(grouplike)}
+}
+
+export function getFlatTrackList(grouplike) {
+  // Underlying function for flattenGrouplike. Can be used if you just want to
+  // get an array and not a grouplike, too.
+
+  return grouplike.items.map(item => {
+    if (isGroup(item)) {
+      return getFlatTrackList(item)
+    } else {
+      return [item]
+    }
+  }).reduce((a, b) => a.concat(b), [])
+}
+
+export function getFlatGroupList(grouplike) {
+  // Analogue of getFlatTrackList for groups instead of tracks. Returns a flat
+  // array of all the groups in each level of the provided grouplike.
+
+  return grouplike.items
+    .filter(isGroup)
+    .map(item => [item, ...getFlatGroupList(item)])
+    .reduce((a, b) => a.concat(b), [])
 }
 
 export function countTotalTracks(item) {
@@ -847,20 +862,17 @@ export function getPathScore(path1, path2) {
   return scores.reduce((a, b) => a < b ? a : b)
 }
 
-export function findTrackObject(referenceData, sourcePlaylist, flattenedSourcePlaylist = null) {
-  // Finds the track object in the source playlist which most closely resembles
+export function findItemObject(referenceData, possibleChoices) {
+  // Finds the item object in the provided choices which most closely resembles
   // the provided reference data. This is used for maintaining the identity of
-  // track objects when reloading a playlist (see serialized-backend.js). It's
-  // also usable in synchronizing the identity of tracks across linked clients
+  // item objects when reloading a playlist (see serialized-backend.js). It's
+  // also usable in synchronizing the identity of items across linked clients
   // (see socket.js).
-  //
-  // NB: This function is many times more efficient if you pass a preemptively
-  // flattened version of the source playlist in as well!
 
-  // Reference data includes track NAME, track SOURCE (downloaderArg), and
-  // track PATH (names of parent groups). Specifics of how existing track
-  // objects are determined to resemble this data are laid out next to the
-  // relevant implementation code.
+  // Reference data includes item NAME, item SOURCE (downloaderArg), and item
+  // PATH (names of parent groups). Specifics of how existing item  objects are
+  // determined to resemble this data are laid out next to the relevant
+  // implementation code.
   //
   // TODO: Should track number be considered here?
   // TODO: Should track "metadata" (duration, md5?) be considered too?
@@ -868,41 +880,37 @@ export function findTrackObject(referenceData, sourcePlaylist, flattenedSourcePl
   //       tracks *is*, and in considering those I lean towards "no" here, but
   //       it's probably worth looking at more in the future. (TM.)
 
-  function getTrackPathScore(track) {
+  function getItemPathScore(item) {
     if (!referenceData.path) {
       return null
     }
 
     const path1 = referenceData.path.slice()
-    const path2 = getItemPath(track).slice(0, -1).map(group => group.name)
+    const path2 = getItemPath(item).slice(0, -1).map(group => group.name)
     return getPathScore(path1, path2)
   }
 
-  if (!flattenedSourcePlaylist) {
-    flattenedSourcePlaylist = flattenGrouplike(sourcePlaylist)
-  }
-
-  // The only tracks which will be considered at all are those which match at
+  // The only items which will be considered at all are those which match at
   // least one of the reference name/source.
-  const baselineResemble = flattenedSourcePlaylist.items.filter(track =>
-    track.name === referenceData.name ||
-    track.downloaderArg === referenceData.downloaderArg)
+  const baselineResemble = possibleChoices.filter(item =>
+    item.name === referenceData.name ||
+    item.downloaderArg && item.downloaderArg === referenceData.downloaderArg)
 
-  // If no track matches the baseline conditions for resemblance at all,
+  // If no item matches the baseline conditions for resemblance at all,
   // return null. It's up to the caller to decide what to do in this case,
-  // e.g. reporting that no track was found, or creating a new track object
+  // e.g. reporting that no item was found, or creating a new item object
   // from the reference data altogether.
   if (!baselineResemble.length) {
     return null
   }
 
-  // Find the "reasons" these tracks resemble the reference data; these will
-  // be used as the factors in calculating which track resembles closest.
-  const reasons = baselineResemble.map(track => ({
-    track,
-    nameMatches: track.name === referenceData.name,
-    sourceMatches: track.downloaderArg === referenceData.downloaderArg,
-    pathScore: getTrackPathScore(track)
+  // Find the "reasons" these items resemble the reference data; these will
+  // be used as the factors in calculating which item resembles closest.
+  const reasons = baselineResemble.map(item => ({
+    item,
+    nameMatches: item.name === referenceData.name,
+    sourceMatches: item.downloaderArg && item.downloaderArg === referenceData.downloaderArg,
+    pathScore: getItemPathScore(item)
   }))
 
   // TODO: The algorithm for determining which track matches closest is
@@ -933,7 +941,7 @@ export function findTrackObject(referenceData, sourcePlaylist, flattenedSourcePl
     mostResembles = reasons[0]
   }
 
-  return mostResembles.track
+  return mostResembles.item
 }
 
 /*
@@ -941,23 +949,27 @@ console.log(getPathScore(['A', 'B', 'C'], ['A', 'B', 'C']))
 console.log(getPathScore(['A', 'B', 'C'], ['A', 'B', 'C', 'D']))
 console.log(getPathScore(['A', 'B', 'C', 'E'], ['A', 'B', 'C']))
 console.log(getPathScore(['W', 'X'], ['Y', 'Z']))
-console.log(findTrackObject(
-  {name: 'T', downloaderArg: 'foo', path: ['A', 'B', 'C']},
-  updateGroupFormat({items: [
-    {id: 1, name: 'T'},
-    {id: 2, name: 'T'},
-    {id: 3, name: 'T'},
-    // {id: 4, name: 'T', downloaderArg: 'foo'},
-    {id: 5, name: 'T'},
-    {id: 6, name: 'Y', downloaderArg: 'foo'},
-    // {name: 'A', items: [
-    //   {name: 'B', items: [
-    //     {name: 'C', items: [
-    //       {name: 'T'}
-    //     ]},
-    //     {name: 'T'}
-    //   ]}
-    // ]}
-  ]})
+console.log(findItemObject(
+  // {name: 'T', downloaderArg: 'foo', path: ['A', 'B', 'C']},
+  {name: 'B'},
+  // getFlatTrackList(
+  getFlatGroupList(
+    updateGroupFormat({items: [
+      {id: 1, name: 'T'},
+      {id: 2, name: 'T'},
+      {id: 3, name: 'T'},
+      // {id: 4, name: 'T', downloaderArg: 'foo'},
+      {id: 5, name: 'T'},
+      {id: 6, name: 'Y', downloaderArg: 'foo'},
+      {name: 'A', items: [
+        {name: 'B', items: [
+          {name: 'C', items: [
+            {name: 'T'}
+          ]},
+          {name: 'T'}
+        ]}
+      ]}
+    ]})
+  )
 ))
 */
diff --git a/serialized-backend.js b/serialized-backend.js
index b43fa49..7ae5e9d 100644
--- a/serialized-backend.js
+++ b/serialized-backend.js
@@ -22,8 +22,12 @@
 'use strict'
 
 import {
-  findTrackObject,
+  isGroup,
+  isTrack,
+  findItemObject,
   flattenGrouplike,
+  getFlatGroupList,
+  getFlatTrackList,
   getItemPath,
 } from './playlist-utils.js'
 
@@ -40,25 +44,11 @@ function getPlayerInfo(queuePlayer) {
 }
 
 export function saveBackend(backend) {
-  function referenceTrack(track) {
-    if (track) {
-      // This is the same format used as referenceData in findTrackObject
-      // (in playlist-utils.js).
-      return {
-        name: track.name,
-        downloaderArg: track.downloaderArg,
-        path: getItemPath(track).slice(0, -1).map(group => group.name)
-      }
-    } else {
-      return null
-    }
-  }
-
   return {
     queuePlayers: backend.queuePlayers.map(QP => ({
       id: QP.id,
-      playingTrack: referenceTrack(QP.playingTrack),
-      queuedTracks: QP.queueGrouplike.items.map(referenceTrack),
+      playingTrack: saveItemReference(QP.playingTrack),
+      queuedTracks: QP.queueGrouplike.items.map(saveItemReference),
       pauseNextTrack: QP.pauseNextTrack,
       playerInfo: getPlayerInfo(QP)
     }))
@@ -77,11 +67,7 @@ export async function restoreBackend(backend, data) {
 
       QP.id = qpData.id
 
-      QP.queueGrouplike.items = qpData.queuedTracks.map(refData => ({
-        [referenceDataSymbol]: refData,
-        name: refData.name,
-        downloaderArg: refData.downloaderArg
-      }))
+      QP.queueGrouplike.items = qpData.queuedTracks.map(refData => restoreNewItem(refData))
 
       QP.player.setVolume(qpData.playerInfo.volume)
       QP.player.setLoop(qpData.playerInfo.isLooping)
@@ -122,7 +108,7 @@ export function updateRestoredTracksUsingPlaylists(backend, playlists) {
   //
   // How well provided tracks resemble the ones existing in the backend (which
   // have not already been replaced by an existing track) is calculated with
-  // the algorithm implemented in findTrackObject, combining all provided
+  // the algorithm implemented in findItemObject, combining all provided
   // playlists (simply putting them all in a group) to allow the algorithm to
   // choose from all playlists equally at once.
   //
@@ -140,8 +126,7 @@ export function updateRestoredTracksUsingPlaylists(backend, playlists) {
   // lessened in the UI by simply opening a new view (rather than a whole new
   // load, with new track identities) when a playlist is opened twice at once.
 
-  const combinedPlaylist = {items: playlists}
-  const flattenedPlaylist = flattenGrouplike(combinedPlaylist)
+  const possibleChoices = getFlatTrackList({items: playlists})
 
   for (const QP of backend.queuePlayers) {
     let playingDataToRestore
@@ -155,7 +140,7 @@ export function updateRestoredTracksUsingPlaylists(backend, playlists) {
     }
 
     if (playingDataToRestore) {
-      const found = findTrackObject(playingDataToRestore, combinedPlaylist, flattenedPlaylist)
+      const found = findItemObject(playingDataToRestore, possibleChoices)
       if (found) {
         restorePlayingTrack(QP, found, qpData.playerInfo || getPlayerInfo(QP))
       }
@@ -167,13 +152,81 @@ export function updateRestoredTracksUsingPlaylists(backend, playlists) {
         return track
       }
 
-      return findTrackObject(refData, combinedPlaylist, flattenedPlaylist) || track
+      return findItemObject(refData, possibleChoices) || track
     })
 
     QP.emit('queue updated')
   }
 }
 
+export function saveItemReference(item) {
+  // Utility function to generate reference data for a track or grouplike,
+  // according to the format taken by findItemObject.
+
+  if (isTrack(item)) {
+    return {
+      name: item.name,
+      path: getItemPath(item).slice(0, -1).map(group => group.name),
+      downloaderArg: item.downloaderArg
+    }
+  } else if (isGroup(item)) {
+    return {
+      name: item.name,
+      path: getItemPath(item).slice(0, -1).map(group => group.name),
+      items: item.items.map(saveItemReference)
+    }
+  } else if (item) {
+    return item
+  } else {
+    return null
+  }
+}
+
+export function restoreNewItem(referenceData, playlists) {
+  // Utility function to restore a new item. If you're restoring tracks
+  // already present in a backend, use the specific function for that,
+  // updateRestoredTracksUsingPlaylists.
+  //
+  // This function takes a playlists array like the function for restoring
+  // tracks in a backend, but in this function, it's optional: if not provided,
+  // it will simply skip searching for a resembling track and return a new
+  // track object right away.
+
+  let found
+  if (playlists) {
+    let possibleChoices
+    if (referenceData.downloaderArg) {
+      possibleChoices = getFlatTrackList({items: playlists})
+    } else if (referenceData.items) {
+      possibleChoices = getFlatGroupList({items: playlists})
+    }
+    if (possibleChoices) {
+      found = findItemObject(referenceData, possibleChoices)
+    }
+  }
+
+  if (found) {
+    return found
+  } else if (referenceData.downloaderArg) {
+    return {
+      [referenceDataSymbol]: referenceData,
+      name: referenceData.name,
+      downloaderArg: referenceData.downloaderArg
+    }
+  } else if (referenceData.items) {
+    return {
+      [referenceDataSymbol]: referenceData,
+      name: referenceData.name,
+      items: referenceData.items.map(item => restoreNewItem(item, playlists))
+    }
+  } else {
+    return {
+      [referenceDataSymbol]: referenceData,
+      name: referenceData.name
+    }
+  }
+}
+
 export function getWaitingTrackData(queuePlayer) {
   // Utility function to get reference data for the track which is currently
   // waiting to be played, once a resembling track is found. This should only
diff --git a/socket.js b/socket.js
index 0ac1a74..be337e2 100644
--- a/socket.js
+++ b/socket.js
@@ -15,10 +15,16 @@ import * as net from 'node:net'
 
 import {
   restoreBackend,
+  restoreNewItem,
   saveBackend,
+  saveItemReference,
   updateRestoredTracksUsingPlaylists,
 } from './serialized-backend.js'
 
+import {
+  silenceEvents
+} from './general-util.js'
+
 function serializeCommandToData(command) {
   // Turn a command into a string/buffer that can be sent over a socket.
   return JSON.stringify(command)
@@ -30,6 +36,38 @@ function deserializeDataToCommand(data) {
   return JSON.parse(data)
 }
 
+function isItemRef(ref) {
+  if (ref === null || typeof ref !== 'object') {
+    return false
+  }
+
+  // List of true/false/null. False means *invalid* reference data; null
+  // means *nonpresent* reference data. True means present and valid.
+  const conditionChecks = [
+    'name' in ref ? typeof ref.name === 'string' : null,
+    'path' in ref ? Array.isArray(ref.path) && ref.path.every(n => typeof n === 'string') : null,
+    'downloaderArg' in ref ? (
+      !('items' in ref) &&
+      typeof ref.downloaderArg === 'string'
+    ) : null,
+    'items' in ref ? (
+      !('downloaderArg' in ref) &&
+      Array.isArray(ref.items) &&
+      ref.items.every(isItemRef)
+    ) : null
+  ]
+
+  if (conditionChecks.includes(false)) {
+    return false
+  }
+
+  if (!conditionChecks.includes(true)) {
+    return false
+  }
+
+  return true
+}
+
 function validateCommand(command) {
   // TODO: Could be used to validate "against" a backend, but for now it just
   // checks data types.
@@ -52,6 +90,51 @@ function validateCommand(command) {
       // clients too.
     case 'client':
       switch (command.code) {
+        case 'clear-queue':
+          return typeof command.queuePlayer === 'string'
+        case 'clear-queue-past':
+        case 'clear-queue-up-to':
+          return (
+            typeof command.queuePlayer === 'string' &&
+            isItemRef(command.topItem)
+          )
+        case 'distribute-queue':
+          return (
+            typeof command.queuePlayer === 'string' &&
+            isItemRef(command.topItem) &&
+            (!command.opts || typeof command.opts === 'object' && (
+              (
+                !command.opts.how ||
+                ['evenly', 'randomly'].includes(command.opts.how)
+              ) &&
+              (
+                !command.opts.rangeEnd ||
+                ['end-of-queue'].includes(command.opts.rangeEnd) ||
+                typeof command.opts.rangeEnd === 'number'
+              )
+            ))
+          )
+        case 'queue':
+          return (
+            typeof command.queuePlayer === 'string' &&
+            isItemRef(command.topItem) &&
+            (
+              isItemRef(command.afterItem) ||
+              [null, 'FRONT'].includes(command.afterItem)
+            ) &&
+            (!command.opts || typeof command.opts === 'object' && (
+              (
+                !command.opts.movePlayingTrack ||
+                typeof command.opts.movePlayingTrack === 'boolean'
+              )
+            ))
+          )
+        case 'restore-queue':
+          return (
+            typeof command.queuePlayer === 'string' &&
+            Array.isArray(command.tracks) &&
+            command.tracks.every(track => isItemRef(track))
+          )
         case 'seek-to':
           return (
             typeof command.queuePlayer === 'string' &&
@@ -64,6 +147,11 @@ function validateCommand(command) {
           )
         case 'status':
           return typeof command.status === 'string'
+        case 'unqueue':
+          return (
+            typeof command.queuePlayer === 'string' &&
+            isItemRef(command.topItem)
+          )
       }
       break
   }
@@ -119,7 +207,7 @@ export function makeSocketServer() {
                 sender: 'server',
                 code: 'set-pause',
                 queuePlayer: QP.id,
-                paused: false
+                paused: QP.player.isPaused
               }))
             }
           }
@@ -130,9 +218,11 @@ export function makeSocketServer() {
       return
     }
 
-    // Relay the data to client sockets.
+    // Relay the command to client sockets besides the sender.
+
+    const otherSockets = sockets.filter(s => s !== socket)
 
-    for (const socket of sockets) {
+    for (const socket of otherSockets) {
       socket.write(JSON.stringify(command))
     }
   }
@@ -231,22 +321,98 @@ export function attachBackendToSocketClient(backend, client, {
         )
 
         switch (command.code) {
+          case 'clear-queue':
+            if (QP) silenceEvents(QP, ['clear-queue'], () => QP.clearQueue())
+            return
+          case 'clear-queue-past':
+            if (QP) silenceEvents(QP, ['clear-queue-past'], () => QP.clearQueuePast(
+              restoreNewItem(command.topItem, getPlaylistSources())
+            ))
+            return
+          case 'clear-queue-up-to':
+            if (QP) silenceEvents(QP, ['clear-queue-up-to'], () => QP.clearQueueUpTo(
+              restoreNewItem(command.topItem, getPlaylistSources())
+            ))
+            return
+          case 'distribute-queue':
+            if (QP) silenceEvents(QP, ['distribute-queue'], () => QP.distributeQueue(
+              restoreNewItem(command.topItem, getPlaylistSources()),
+              {
+                how: command.opts.how,
+                rangeEnd: command.opts.rangeEnd
+              }
+            ))
+            return
+          case 'queue':
+            if (QP) silenceEvents(QP, ['queue'], () => QP.queue(
+              restoreNewItem(command.topItem, getPlaylistSources()),
+              isItemRef(command.afterItem) ? restoreNewItem(command.afterItem, getPlaylistSources()) : command.afterItem,
+              {
+                movePlayingTrack: command.opts.movePlayingTrack
+              }
+            ))
+            return
+          case 'restore-queue':
+            if (QP) {
+              QP.queueGrouplike.items = command.tracks.map(refData => restoreNewItem(refData))
+              // TODO: target just the one queue player. hacks = illegal
+              updateRestoredTracksUsingPlaylists(backend, getPlaylistSources())
+            }
           case 'seek-to':
-            if (QP) QP.seekTo(command.time)
+            if (QP) silenceEvents(QP, ['seek-to'], () => QP.seekTo(command.time))
             return
           case 'set-pause':
-            if (QP) QP.setPause(command.paused)
+            if (QP) silenceEvents(QP, ['set-pause'], () => QP.setPause(command.paused))
+            return
+          case 'unqueue':
+            if (QP) silenceEvents(QP, ['unqueue'], () => QP.unqueue(
+              restoreNewItem(command.topItem, getPlaylistSources())
+            ))
             return
         }
       }
     }
   })
 
-  backend.on('toggle-pause', queuePlayer => {
+  backend.on('clear-queue', queuePlayer => {
     client.sendCommand({
-      code: 'set-pause',
+      code: 'clear-queue',
+      queuePlayer: queuePlayer.id
+    })
+  })
+
+  backend.on('clear-queue-past', (queuePlayer, topItem) => {
+    client.sendCommand({
+      code: 'clear-queue-past',
       queuePlayer: queuePlayer.id,
-      paused: queuePlayer.player.isPaused
+      topItem: saveItemReference(topItem)
+    })
+  })
+
+  backend.on('clear-queue-up-to', (queuePlayer, topItem) => {
+    client.sendCommand({
+      code: 'clear-queue-up-to',
+      queuePlayer: queuePlayer.id,
+      topItem: saveItemReference(topItem)
+    })
+  })
+
+  backend.on('distribute-queue', (queuePlayer, topItem, opts) => {
+    client.sendCommand({
+      code: 'distribute-queue',
+      queuePlayer: queuePlayer.id,
+      topItem: saveItemReference(topItem),
+      opts
+    })
+  })
+
+  backend.on('queue', (queuePlayer, topItem, afterItem, opts) => {
+    client.sendCommand({
+      code: 'queue',
+      queuePlayer: queuePlayer.id,
+      topItem: saveItemReference(topItem),
+      afterItem: saveItemReference(afterItem),
+      opts
     })
   })
 
@@ -261,6 +427,30 @@ export function attachBackendToSocketClient(backend, client, {
   backend.on('seek-ahead', handleSeek)
   backend.on('seek-back', handleSeek)
   backend.on('seek-to', handleSeek)
+
+  backend.on('shuffle-queue', queuePlayer => {
+    client.sendCommand({
+      code: 'restore-queue',
+      queuePlayer: queuePlayer.id,
+      tracks: queuePlayer.queueGrouplike.items.map(saveItemReference)
+    })
+  })
+
+  backend.on('toggle-pause', queuePlayer => {
+    client.sendCommand({
+      code: 'set-pause',
+      queuePlayer: queuePlayer.id,
+      paused: queuePlayer.player.isPaused
+    })
+  })
+
+  backend.on('unqueue', (queuePlayer, topItem) => {
+    client.sendCommand({
+      code: 'unqueue',
+      queuePlayer: queuePlayer.id,
+      topItem: saveItemReference(topItem)
+    })
+  })
 }
 
 export function attachSocketServerToBackend(server, backend) {