« 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.js128
-rw-r--r--general-util.js14
-rwxr-xr-xindex.js41
-rw-r--r--package-lock.json260
-rw-r--r--package.json7
-rw-r--r--players.js10
-rw-r--r--playlist-utils.js281
-rw-r--r--serialized-backend.js245
-rw-r--r--socket.js720
-rw-r--r--ui.js122
10 files changed, 1757 insertions, 71 deletions
diff --git a/backend.js b/backend.js
index 81726c6..d222d10 100644
--- a/backend.js
+++ b/backend.js
@@ -8,6 +8,7 @@ const { getMetadataReaderFor } = require('./metadata-readers')
 const { getPlayer } = require('./players')
 const RecordStore = require('./record-store')
 const os = require('os')
+const shortid = require('shortid')
 
 const {
   getTimeStringsFromSec,
@@ -62,6 +63,8 @@ class QueuePlayer extends EventEmitter {
   }) {
     super()
 
+    this.id = shortid.generate()
+
     this.player = null
     this.playingTrack = null
     this.queueGrouplike = {name: 'Queue', isTheQueue: true, items: []}
@@ -69,6 +72,10 @@ class QueuePlayer extends EventEmitter {
     this.loopQueueAtEnd = false
     this.playedTrackToEnd = false
     this.timeData = null
+    this.time = null
+
+    this.alwaysStartPaused = false
+    this.waitWhenDonePlaying = false
 
     this.getPlayer = getPlayer
     this.getRecordFor = getRecordFor
@@ -86,6 +93,7 @@ class QueuePlayer extends EventEmitter {
     this.player.on('printStatusLine', data => {
       if (this.playingTrack) {
         this.timeData = data
+        this.time = data.curSecTotal
         this.emit('received time data', data, this)
       }
     })
@@ -161,6 +169,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.
@@ -169,9 +178,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
@@ -237,6 +249,7 @@ class QueuePlayer extends EventEmitter {
       }
     }
 
+    this.emit('distribute-queue', topItem, {how, rangeEnd})
     this.emitQueueUpdated()
   }
 
@@ -281,11 +294,17 @@ class QueuePlayer extends EventEmitter {
     }
 
     recursivelyUnqueueTracks(topItem)
+    this.emit('unqueue', topItem)
     this.emitQueueUpdated()
 
     return focusItem
   }
 
+  replaceAllItems(newItems) {
+    this.queueGrouplike.items = newItems
+    this.emitQueueUpdated()
+  }
+
   clearQueuePast(track) {
     const { items } = this.queueGrouplike
     const index = items.indexOf(track) + 1
@@ -298,6 +317,7 @@ class QueuePlayer extends EventEmitter {
       items.splice(index)
     }
 
+    this.emit('clear-queue-past', track)
     this.emitQueueUpdated()
   }
 
@@ -314,6 +334,7 @@ class QueuePlayer extends EventEmitter {
       items.splice(startIndex, endIndex - startIndex)
     }
 
+    this.emit('clear-queue-up-to', track)
     this.emitQueueUpdated()
   }
 
@@ -344,6 +365,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()
   }
 
@@ -352,6 +374,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()
   }
 
@@ -368,7 +391,7 @@ class QueuePlayer extends EventEmitter {
   }
 
 
-  async play(item) {
+  async play(item, forceStartPaused) {
     if (this.player === null) {
       throw new Error('Attempted to play before a player was loaded')
     }
@@ -414,11 +437,15 @@ class QueuePlayer extends EventEmitter {
       }
 
       this.timeData = null
+      this.time = null
       this.playingTrack = item
+      this.emit('playing details', this.playingTrack, oldTrack, this)
       this.emit('playing', this.playingTrack, oldTrack, this)
 
       await this.player.kill()
-      if (this.playedTrackToEnd) {
+      if (this.alwaysStartPaused || forceStartPaused) {
+        this.player.setPause(true)
+      } else if (this.playedTrackToEnd) {
         this.player.setPause(this.pauseNextTrack)
         this.pauseNextTrack = false
         this.playedTrackToEnd = false
@@ -433,11 +460,14 @@ class QueuePlayer extends EventEmitter {
 
     if (playingThisTrack) {
       this.playedTrackToEnd = true
-      if (!this.playNext(item)) {
-        if (this.loopQueueAtEnd) {
-          this.playFirst()
-        } else {
-          this.clearPlayingTrack()
+      this.emit('done playing', this.playingTrack)
+      if (!this.waitWhenDonePlaying) {
+        if (!this.playNext(item)) {
+          if (this.loopQueueAtEnd) {
+            this.playFirst()
+          } else {
+            this.clearPlayingTrack()
+          }
         }
       }
     }
@@ -515,6 +545,8 @@ class QueuePlayer extends EventEmitter {
       const oldTrack = this.playingTrack
       this.playingTrack = null
       this.timeData = null
+      this.time = null
+      this.emit('playing details', null, oldTrack, this)
       this.emit('playing', null, oldTrack, this)
     }
   }
@@ -524,51 +556,73 @@ class QueuePlayer extends EventEmitter {
   }
 
   seekAhead(seconds) {
+    this.time += seconds
     this.player.seekAhead(seconds)
+    this.emit('seek-ahead', +seconds)
   }
 
   seekBack(seconds) {
+    if (this.time < seconds) {
+      this.time = 0
+    } else {
+      this.time -= seconds
+    }
     this.player.seekBack(seconds)
+    this.emit('seek-back', +seconds)
+  }
+
+  seekTo(timeInSecs) {
+    this.time = timeInSecs
+    this.player.seekTo(timeInSecs)
+    this.emit('seek-to', +timeInSecs)
   }
 
   togglePause() {
     this.player.togglePause()
+    this.emit('toggle-pause')
   }
 
   setPause(value) {
     this.player.setPause(value)
+    this.emit('set-pause', !!value)
   }
 
   toggleLoop() {
     this.player.toggleLoop()
+    this.emit('toggle-loop')
   }
 
   setLoop(value) {
     this.player.setLoop(value)
+    this.emit('set-loop', !!value)
   }
 
   volUp(amount = 10) {
     this.player.volUp(amount)
+    this.emit('vol-up', +amount)
   }
 
   volDown(amount = 10) {
     this.player.volDown(amount)
+    this.emit('vol-down', +amount)
   }
 
   setVolume(value) {
     this.player.setVolume(value)
+    this.emit('set-volume', +value)
   }
 
   setVolumeMultiplier(value) {
-    this.player.setVolumeMultiplier(value);
+    this.player.setVolumeMultiplier(value)
   }
 
   fadeIn() {
-    return this.player.fadeIn();
+    return this.player.fadeIn()
   }
 
   setPauseNextTrack(value) {
     this.pauseNextTrack = !!value
+    this.emit('set-pause-next-track', !!value)
   }
 
   setLoopQueueAtEnd(value) {
@@ -613,6 +667,8 @@ class Backend extends EventEmitter {
     }
 
     this.queuePlayers = []
+    this.alwaysStartPaused = false
+    this.waitWhenDonePlaying = false
 
     this.recordStore = new RecordStore()
     this.throttleMetadata = throttlePromise(10)
@@ -644,9 +700,39 @@ class Backend extends EventEmitter {
       return error
     }
 
+    queuePlayer.alwaysStartPaused = this.alwaysStartPaused
+    queuePlayer.waitWhenDonePlaying = this.waitWhenDonePlaying
+
     this.queuePlayers.push(queuePlayer)
     this.emit('added queue player', queuePlayer)
 
+    for (const event of [
+      'playing',
+      'done playing',
+      'queue',
+      'distribute-queue',
+      'unqueue',
+      'clear-queue-past',
+      'clear-queue-up-to',
+      'shuffle-queue',
+      'clear-queue',
+      'queue updated',
+      'seek-ahead',
+      'seek-back',
+      'toggle-pause',
+      'set-pause',
+      'toggle-loop',
+      'set-loop',
+      'vol-up',
+      'vol-down',
+      'set-volume',
+      'set-pause-next-track'
+    ]) {
+      queuePlayer.on(event, (...data) => {
+        this.emit(event, queuePlayer, ...data)
+      })
+    }
+
     return queuePlayer
   }
 
@@ -743,6 +829,20 @@ class Backend extends EventEmitter {
     return {seconds, string, noticedMissingMetadata, approxSymbol}
   }
 
+  setAlwaysStartPaused(value) {
+    this.alwaysStartPaused = !!value
+    for (const queuePlayer of this.queuePlayers) {
+      queuePlayer.alwaysStartPaused = !!value
+    }
+  }
+
+  setWaitWhenDonePlaying(value) {
+    this.waitWhenDonePlaying = !!value
+    for (const queuePlayer of this.queuePlayers) {
+      queuePlayer.waitWhenDonePlaying = !!value
+    }
+  }
+
   async stopPlayingAll() {
     for (const queuePlayer of this.queuePlayers) {
       await queuePlayer.stopPlaying()
@@ -752,6 +852,10 @@ class Backend extends EventEmitter {
   async download(item) {
     return download(item, this.getRecordFor(item))
   }
+
+  showLogMessage(text) {
+    this.emit('log message', text)
+  }
 }
 
 module.exports = Backend
diff --git a/general-util.js b/general-util.js
index 0a81cdc..0f5bdd5 100644
--- a/general-util.js
+++ b/general-util.js
@@ -310,3 +310,17 @@ const parseOptions = async function(options, optionDescriptorMap) {
 parseOptions.handleDashless = Symbol()
 
 module.exports.parseOptions = parseOptions
+
+module.exports.silenceEvents = async function(emitter, eventsToSilence, callback) {
+  const oldEmit = emitter.emit
+
+  emitter.emit = function(event, ...data) {
+    if (!eventsToSilence.includes(event)) {
+      oldEmit.apply(emitter, [event, ...data])
+    }
+  }
+
+  await callback()
+
+  emitter.emit = oldEmit
+}
diff --git a/index.js b/index.js
index 444d579..d628a20 100755
--- a/index.js
+++ b/index.js
@@ -12,6 +12,13 @@ const processSmartPlaylist = require('./smart-playlist')
 const setupClient = require('./client')
 
 const {
+  makeSocketServer,
+  makeSocketClient,
+  attachBackendToSocketClient,
+  attachSocketServerToBackend
+} = require('./socket')
+
+const {
   getItemPathString,
   updatePlaylistFormat
 } = require('./playlist-utils')
@@ -67,6 +74,9 @@ async function main() {
     },
     'player-options': {type: 'series'},
     'stress-test': {type: 'flag'},
+    'socket-client': {type: 'value'},
+    'socket-name': {type: 'value'},
+    'socket-server': {type: 'value'},
     'telnet-server': {type: 'flag'},
     [parseOptions.handleDashless](option) {
       playlistSources.push(option)
@@ -138,6 +148,37 @@ async function main() {
     appElement.attachAsServerHost(telnetServer)
   }
 
+  let socketClient
+  let socketServer
+  if (options['socket-server']) {
+    socketServer = makeSocketServer()
+    attachSocketServerToBackend(socketServer, backend)
+    socketServer.listen(options['socket-server'])
+
+    socketClient = makeSocketClient()
+    socketClient.socket.connect(options['socket-server'])
+  }
+
+  if (options['socket-client']) {
+    socketClient = makeSocketClient()
+    const [ p1, p2 ] = options['socket-client'].split(':')
+    const host = p2 && p1
+    const port = p2 ? p2 : p1
+    socketClient.socket.connect(port, host)
+  }
+
+  if (socketClient) {
+    attachBackendToSocketClient(backend, socketClient, {
+      getPlaylistSources: () => appElement.playlistSources
+    })
+
+    let nickname = process.env.USER
+    if (options['socket-name']) {
+      nickname = options['socket-name']
+    }
+    socketClient.setNickname(nickname)
+  }
+
   if (options['stress-test']) {
     await loadPlaylistPromise
 
diff --git a/package-lock.json b/package-lock.json
index 592e796..3d18627 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,8 +1,207 @@
 {
   "name": "mtui",
   "version": "0.0.1",
-  "lockfileVersion": 1,
+  "lockfileVersion": 2,
   "requires": true,
+  "packages": {
+    "": {
+      "version": "0.0.1",
+      "license": "GPL-3.0",
+      "dependencies": {
+        "command-exists": "^1.2.9",
+        "expand-home-dir": "0.0.3",
+        "mkdirp": "^0.5.5",
+        "natural-orderby": "^2.0.3",
+        "node-fetch": "^2.6.0",
+        "open": "^7.0.4",
+        "sanitize-filename": "^1.6.3",
+        "shortid": "^2.2.15",
+        "tempy": "^0.2.1",
+        "tui-lib": "^0.3.1"
+      },
+      "bin": {
+        "mtui": "index.js"
+      }
+    },
+    "node_modules/clone": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
+      "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/command-exists": {
+      "version": "1.2.9",
+      "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz",
+      "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w=="
+    },
+    "node_modules/crypto-random-string": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz",
+      "integrity": "sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/defaults": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz",
+      "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=",
+      "dependencies": {
+        "clone": "^1.0.2"
+      }
+    },
+    "node_modules/expand-home-dir": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/expand-home-dir/-/expand-home-dir-0.0.3.tgz",
+      "integrity": "sha1-ct6KBIbMKKO71wRjU5iCW1tign0="
+    },
+    "node_modules/is-docker": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.0.0.tgz",
+      "integrity": "sha512-pJEdRugimx4fBMra5z2/5iRdZ63OhYV0vr0Dwm5+xtW4D1FvRkB8hamMIhnWfyJeDdyr/aa7BDyNbtG38VxgoQ==",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-wsl": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+      "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+      "dependencies": {
+        "is-docker": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/minimist": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
+      "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
+    },
+    "node_modules/mkdirp": {
+      "version": "0.5.5",
+      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz",
+      "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==",
+      "dependencies": {
+        "minimist": "^1.2.5"
+      },
+      "bin": {
+        "mkdirp": "bin/cmd.js"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "2.1.11",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-2.1.11.tgz",
+      "integrity": "sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA=="
+    },
+    "node_modules/natural-orderby": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-2.0.3.tgz",
+      "integrity": "sha512-p7KTHxU0CUrcOXe62Zfrb5Z13nLvPhSWR/so3kFulUQU0sgUll2Z0LwpsLN351eOOD+hRGu/F1g+6xDfPeD++Q==",
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/node-fetch": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz",
+      "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==",
+      "engines": {
+        "node": "4.x || >=6.0.0"
+      }
+    },
+    "node_modules/open": {
+      "version": "7.0.4",
+      "resolved": "https://registry.npmjs.org/open/-/open-7.0.4.tgz",
+      "integrity": "sha512-brSA+/yq+b08Hsr4c8fsEW2CRzk1BmfN3SAK/5VCHQ9bdoZJ4qa/+AfR0xHjlbbZUyPkUHs1b8x1RqdyZdkVqQ==",
+      "dependencies": {
+        "is-docker": "^2.0.0",
+        "is-wsl": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/sanitize-filename": {
+      "version": "1.6.3",
+      "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz",
+      "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==",
+      "dependencies": {
+        "truncate-utf8-bytes": "^1.0.0"
+      }
+    },
+    "node_modules/shortid": {
+      "version": "2.2.15",
+      "resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.15.tgz",
+      "integrity": "sha512-5EaCy2mx2Jgc/Fdn9uuDuNIIfWBpzY4XIlhoqtXF6qsf+/+SGZ+FxDdX/ZsMZiWupIWNqAEmiNY4RC+LSmCeOw==",
+      "dependencies": {
+        "nanoid": "^2.1.0"
+      }
+    },
+    "node_modules/temp-dir": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz",
+      "integrity": "sha1-CnwOom06Oa+n4OvqnB/AvE2qAR0=",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/tempy": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.2.1.tgz",
+      "integrity": "sha512-LB83o9bfZGrntdqPuRdanIVCPReam9SOZKW0fOy5I9X3A854GGWi0tjCqoXEk84XIEYBc/x9Hq3EFop/H5wJaw==",
+      "dependencies": {
+        "temp-dir": "^1.0.0",
+        "unique-string": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/truncate-utf8-bytes": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
+      "integrity": "sha1-QFkjkJWS1W94pYGENLC3hInKXys=",
+      "dependencies": {
+        "utf8-byte-length": "^1.0.1"
+      }
+    },
+    "node_modules/tui-lib": {
+      "version": "0.3.1",
+      "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.3.1.tgz",
+      "integrity": "sha512-uCE2j351/b4C2Q3eEhC54EvZiWbgJ/Q3gH5ElS2D+mvRmWbHDzXbPUhcXrx8oOA5rZFZ4iNVMCoLCqzWWZTJyQ==",
+      "dependencies": {
+        "wcwidth": "^1.0.1"
+      }
+    },
+    "node_modules/unique-string": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz",
+      "integrity": "sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=",
+      "dependencies": {
+        "crypto-random-string": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/utf8-byte-length": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz",
+      "integrity": "sha1-9F8VDExm7uloGGUFq5P8u4rWv2E="
+    },
+    "node_modules/wcwidth": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
+      "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=",
+      "dependencies": {
+        "defaults": "^1.0.3"
+      }
+    }
+  },
   "dependencies": {
     "clone": {
       "version": "1.0.4",
@@ -38,9 +237,12 @@
       "integrity": "sha512-pJEdRugimx4fBMra5z2/5iRdZ63OhYV0vr0Dwm5+xtW4D1FvRkB8hamMIhnWfyJeDdyr/aa7BDyNbtG38VxgoQ=="
     },
     "is-wsl": {
-      "version": "2.1.1",
-      "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.1.1.tgz",
-      "integrity": "sha512-umZHcSrwlDHo2TGMXv0DZ8dIUGunZ2Iv68YZnrmCiBPkZ4aaOhtv7pXJKeki9k3qJ3RJr0cDyitcl5wEH3AYog=="
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+      "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+      "requires": {
+        "is-docker": "^2.0.0"
+      }
     },
     "minimist": {
       "version": "1.2.5",
@@ -55,6 +257,11 @@
         "minimist": "^1.2.5"
       }
     },
+    "nanoid": {
+      "version": "2.1.11",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-2.1.11.tgz",
+      "integrity": "sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA=="
+    },
     "natural-orderby": {
       "version": "2.0.3",
       "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-2.0.3.tgz",
@@ -66,9 +273,9 @@
       "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA=="
     },
     "open": {
-      "version": "7.0.3",
-      "resolved": "https://registry.npmjs.org/open/-/open-7.0.3.tgz",
-      "integrity": "sha512-sP2ru2v0P290WFfv49Ap8MF6PkzGNnGlAwHweB4WR4mr5d2d0woiCluUeJ218w7/+PmoBy9JmYgD5A4mLcWOFA==",
+      "version": "7.0.4",
+      "resolved": "https://registry.npmjs.org/open/-/open-7.0.4.tgz",
+      "integrity": "sha512-brSA+/yq+b08Hsr4c8fsEW2CRzk1BmfN3SAK/5VCHQ9bdoZJ4qa/+AfR0xHjlbbZUyPkUHs1b8x1RqdyZdkVqQ==",
       "requires": {
         "is-docker": "^2.0.0",
         "is-wsl": "^2.1.1"
@@ -82,6 +289,14 @@
         "truncate-utf8-bytes": "^1.0.0"
       }
     },
+    "shortid": {
+      "version": "2.2.15",
+      "resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.15.tgz",
+      "integrity": "sha512-5EaCy2mx2Jgc/Fdn9uuDuNIIfWBpzY4XIlhoqtXF6qsf+/+SGZ+FxDdX/ZsMZiWupIWNqAEmiNY4RC+LSmCeOw==",
+      "requires": {
+        "nanoid": "^2.1.0"
+      }
+    },
     "temp-dir": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz",
@@ -105,31 +320,11 @@
       }
     },
     "tui-lib": {
-      "version": "0.2.1",
-      "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.2.1.tgz",
-      "integrity": "sha512-AHyhA9neF8tM5dAJnggKIO1W0w5pSVjuuYryp/bMJee6ol2kIzd8p4mbri0Es6/BP9bvPdYFjhSddWwzAE0TpQ==",
-      "requires": {
-        "wcwidth": "^1.0.1",
-        "word-wrap": "^1.2.3"
-      }
-    },
-    "tui-text-editor": {
       "version": "0.3.1",
-      "resolved": "https://registry.npmjs.org/tui-text-editor/-/tui-text-editor-0.3.1.tgz",
-      "integrity": "sha512-ySLdKfUHwxt6W1hub7Qt7smtuwujRHWxMIwdnO+IOzhd2B9naIg07JDr2LISZ3X+SZg0mvBNcGGeTf+L8bcSpw==",
+      "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.3.1.tgz",
+      "integrity": "sha512-uCE2j351/b4C2Q3eEhC54EvZiWbgJ/Q3gH5ElS2D+mvRmWbHDzXbPUhcXrx8oOA5rZFZ4iNVMCoLCqzWWZTJyQ==",
       "requires": {
-        "tui-lib": "^0.1.1"
-      },
-      "dependencies": {
-        "tui-lib": {
-          "version": "0.1.1",
-          "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.1.1.tgz",
-          "integrity": "sha512-QAE4axNCJ42IZSNnc2pLOkFtzHqYFgenDyw88JHHRNd8PXTVO8+JIpJArpgAguopd4MmoYaJbreze0BHoWMXfA==",
-          "requires": {
-            "wcwidth": "^1.0.1",
-            "word-wrap": "^1.2.3"
-          }
-        }
+        "wcwidth": "^1.0.1"
       }
     },
     "unique-string": {
@@ -152,11 +347,6 @@
       "requires": {
         "defaults": "^1.0.3"
       }
-    },
-    "word-wrap": {
-      "version": "1.2.3",
-      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
-      "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ=="
     }
   }
 }
diff --git a/package.json b/package.json
index 421b0a4..1cbcb9c 100644
--- a/package.json
+++ b/package.json
@@ -14,11 +14,10 @@
     "mkdirp": "^0.5.5",
     "natural-orderby": "^2.0.3",
     "node-fetch": "^2.6.0",
-    "open": "^7.0.3",
+    "open": "^7.0.4",
     "sanitize-filename": "^1.6.3",
+    "shortid": "^2.2.15",
     "tempy": "^0.2.1",
-    "tui-lib": "^0.2.1",
-    "tui-text-editor": "^0.3.1",
-    "word-wrap": "^1.2.3"
+    "tui-lib": "^0.3.1"
   }
 }
diff --git a/players.js b/players.js
index e22e505..dde1fbf 100644
--- a/players.js
+++ b/players.js
@@ -224,13 +224,15 @@ module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer {
   }
 
   setPause(val) {
-    this.isPaused = !!val
-    this.sendCommand('set', 'pause', this.isPaused)
+    if (!!val !== this.isPaused) {
+      this.togglePause()
+    }
   }
 
   setLoop(val) {
-    this.isLooping = !!val
-    this.sendCommand('set', 'loop', this.isLooping)
+    if (!!val !== this.isLooping) {
+      this.toggleLoop()
+    }
   }
 
   async kill() {
diff --git a/playlist-utils.js b/playlist-utils.js
index 68cba56..5fbfff8 100644
--- a/playlist-utils.js
+++ b/playlist-utils.js
@@ -166,15 +166,30 @@ 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)}
+}
+
+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), [])
+}
+
+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), [])
 }
 
 function countTotalTracks(item) {
@@ -687,12 +702,226 @@ function getCorrespondingPlayableForFile(item) {
   return parent.items.find(item => isPlayable(item) && path.basename(item.url, path.extname(item.url)) === basename)
 }
 
+function getPathScore(path1, path2) {
+  // This function is basically only used in findTrackObject, but it's kinda
+  // huge and I need to test that it works outside of that context, so I'm
+  // sticking it on the global scope. Feel free to steal for whatever your
+  // weird future need for comparing any two paths is!
+  //
+  // path1 and path2 should be arrays of group names, according to the path
+  // you'd follow to open the groups and access a contained track. They should
+  // *not* include the track name, unless you want those to be considered a
+  // valid place for the paths to cross over!
+  //
+  // --
+  //
+  // A path score is determined to be the number of groups which must be
+  // traversed across the two paths to find a matching group name and then
+  // reach the other track under that group. A lower score implies a closer
+  // match (since score increases not with "closeness" but "separation").
+  //
+  // For example, these two paths are considered to have a score of zero
+  // against each other ("T" represents the track):
+  //
+  //   X/B/C/T
+  //   Y/B/C/T
+  //
+  // Their separation is zero because, starting from the closest (i.e. top)
+  // group to either the provided track or the reference data track, it takes
+  // zero additional steps to reach a group whose name is shared between the
+  // two paths: those top groups already have the same name.
+  //
+  // The above example indicates that the pattern before the closest matching
+  // path does not matter. Indeed, the actual length of the path could be
+  // different (W/X/B/C versus Y/B/C for example), and the score would still
+  // be the same. Parts of the path prepending the closest matching group
+  // name are thus ommitted from following examples.
+  //
+  // These paths, on the other hand, have a score of one:
+  //
+  //   (...)/C/T
+  //   (...)/C/D/T
+  //
+  // The closest matching name in this path is C. It is zero steps further
+  // from the start of the first path (C is the start); on the other path,
+  // it is one step further (D must be passed first). Therefore, the total
+  // steps that must be travelled to reach the start of one path to the
+  // start of the other by passing through the closest overlapping name is
+  // one: 0 + 1 = 1.
+  //
+  // In determining which of two paths are a closer match to a provided
+  // reference path, it's important to remember that a lower score (implying
+  // less separation) is better. Though we'll see the following example is
+  // probably more common across most music libraries, a reasonably natural
+  // example of the path structures above occurring in a music library could
+  // be this: an artist directory containing both albums and stray tracks,
+  // where one track apparently appears as both a stray track file and in an
+  // adjacent album directory; or, a mixtape which contains adjacent to its
+  // mixed-segment track listing a folder of the unmixed segments.
+  //
+  // These paths have a score of two:
+  //
+  //   (...)/B/C/T
+  //   (...)/B/D/T
+  //
+  // With the above examples, this one is fairly self explanatory. In this
+  // case, the closest matching group, B, is one step away from the start
+  // point (the first group before the track, i.e, the top name in the path)
+  // in both paths. Summed, the distance (and thus the score) is two.
+  //
+  // This example demonstrates what is probably a more realistic case of two
+  // tracks resembling each other (e.g. having the same name or source) but
+  // not sharing the same path: if B represents an artist, and C & D stand in
+  // place (in this example) of the names of that artist's albums, then it is
+  // reasonable to say the directories for the album are slightly different
+  // across the two paths. This could be the case for two users who ended up
+  // naming the album directory differently, or for one user restoring from
+  // their own backend/playlist after having adjusted the naming structure of
+  // their music library. It's also possible that there could simply be two
+  // albums by the same artist which contain a track of the same name; in
+  // that case, the path score implementation is doing exactly its job by
+  // indicating that these tracks would have a greater score (meaning further
+  // separation) than when checking against the track belonging to the same
+  // release. (If there is concern that such a track should not match at all
+  // because it may be a remarkably different track, other factors of
+  // resemblance -- position in album, duration, etc -- can be used to add
+  // detail to the apparent level of resemblance then.)
+  //
+  // --
+  //
+  // A note on determining which name is the "closest" -- consider
+  // the following two paths:
+  //
+  //   A/X/B/C/D/E/T
+  //   A/Y/E/B/C/D/T
+  //
+  // There are many names which appear in both paths. So which do we treat
+  // as the closest? Well, what we're looking for is the shortest path across
+  // both paths, passing through at a particular name. To do this, we simply
+  // calculate the score for each name in the intersection of both paths
+  // (i.e. every name which shows up in both paths) using the same algorithm
+  // described above (sum of the distance from the start of either path).
+  // Then we take the lowest resultant score, and use that as the final score
+  // which is returned out of this function.
+  //
+  // TODO: There are probably optimizations to be made as far as avoiding
+  //       processing every overlapping name goes (particularly once it's
+  //       determined that no other path could be determined), but honestly
+  //       I'm pretty sure if I tried to write an algorithm taking *that*
+  //       into account, I'd end up screwing it up. :P So for now, we just
+  //       do a simple filter and reduce operation.
+  //
+  // If the intersection of the two paths is empty (i.e. there is no overlap),
+  // we return the otherwise nonsense value, -1.
+
+  const union = Array.from(new Set([...path1, ...path2]))
+  const intersection = union.filter(
+    name => path1.includes(name) && path2.includes(name))
+
+  if (!intersection.length) {
+    return -1
+  }
+
+  const reversed1 = path1.reverse()
+  const reversed2 = path2.reverse()
+
+  const scores = intersection.map(
+    name => reversed1.indexOf(name) + reversed2.indexOf(name))
+
+  return scores.reduce((a, b) => a < b ? a : b)
+}
+
+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
+  // 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).
+
+  // 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?
+  //       This in particular prompts questions of what the purpose of matching
+  //       tracks *is*, and in considering those I lean towards "no" here, but
+  //       it's probably worth looking at more in the future. (TM.)
+
+  function getItemPathScore(item) {
+    if (!referenceData.path) {
+      return null
+    }
+
+    const path1 = referenceData.path.slice()
+    const path2 = getItemPath(item).slice(0, -1).map(group => group.name)
+    return getPathScore(path1, path2)
+  }
+
+  // The only items which will be considered at all are those which match at
+  // least one of the reference name/source.
+  const baselineResemble = possibleChoices.filter(item =>
+    item.name === referenceData.name ||
+    item.downloaderArg && item.downloaderArg === referenceData.downloaderArg)
+
+  // 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 item was found, or creating a new item object
+  // from the reference data altogether.
+  if (!baselineResemble.length) {
+    return null
+  }
+
+  // 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
+  //       rudimentary at best right now. It would be well improved with
+  //       better-detailed reasoning! That said, here's the current code
+  //       laid out explicitly:
+  //
+  // Initial sort by matches is (NAME & SOURCE), SOURCE, NAME. Track which
+  // most closely matches path is returned thereafter, with ties broken by
+  // the initial sort. (If name, source, *and* path all are equal, first
+  // track as ordered in the source playlist/parent group is returned.)
+  // If no tracks have any path overlap, the first item in the sorted list
+  // is returned (since it is the closest match). (Again, ties here are
+  // broken according to source ordering.)
+
+  reasons.sort((a, b) =>
+    a.sourceMatches && !b.sourceMatches ? -1 :
+    !a.sourceMatches && b.sourceMatches ? 1 :
+    a.nameMatches && !b.nameMatches ? -1 :
+    !a.nameMatches && b.nameMatches ? 1 :
+    0)
+
+  let mostResembles
+  const sharePath = reasons.filter(({ pathScore }) => pathScore >= 0)
+  if (sharePath.length) {
+    mostResembles = sharePath.reduce((a, b) => a.pathScore < b.pathScore ? a : b)
+  } else {
+    mostResembles = reasons[0]
+  }
+
+  return mostResembles.item
+}
+
 module.exports = {
   parentSymbol,
   updatePlaylistFormat, updateGroupFormat, updateTrackFormat,
   cloneGrouplike,
   filterTracks,
-  flattenGrouplike, countTotalTracks,
+  flattenGrouplike,
+  getFlatTrackList,
+  getFlatGroupList,
+  countTotalTracks,
   shuffleOrderOfGroups,
   reverseOrderOfGroups,
   partiallyFlattenGrouplike, collapseGrouplike,
@@ -707,6 +936,38 @@ module.exports = {
   searchForItem,
   getCorrespondingFileForItem,
   getCorrespondingPlayableForFile,
+  getPathScore,
+  findItemObject,
   isGroup, isTrack,
   isOpenable, isPlayable
 }
+
+if (require.main === module) {
+  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(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
new file mode 100644
index 0000000..a3f02fa
--- /dev/null
+++ b/serialized-backend.js
@@ -0,0 +1,245 @@
+// Tools for serializing a backend into a JSON-stringifiable object format,
+// and for deserializing this format and loading its contained data into an
+// existing backend instance.
+//
+// Serialized data includes the list of queue players and each player's state
+// (queued items, playback position, etc).
+//
+// Serialized backend data can be used for a variety of purposes, such as
+// writing the data to a file and saving it for later use, or transferring
+// it over an internet connection to synchronize playback with a friend.
+// (The code in socket.js exists to automate this process, as well as to
+// provide a link so that changes to the queue or playback are synchronized
+// in real-time.)
+//
+// TODO: Changes might be necessary all throughout the program to support
+// having any number of objects refer to "the same track", as will likely be
+// the case when restoring from a serialized backend. One way to handle this
+// would be to (perhaps through the existing record store code) keep a handle
+// on each of "the same track", which would be accessed by something like a
+// serialized ID (ala symbols), or maybe just the track name / source URL.
+
+'use strict'
+
+const {
+  isGroup,
+  isTrack,
+  findItemObject,
+  flattenGrouplike,
+  getFlatGroupList,
+  getFlatTrackList,
+  getItemPath
+} = require('./playlist-utils')
+
+const referenceDataSymbol = Symbol('Restored reference data')
+
+function getPlayerInfo(queuePlayer) {
+  const { player } = queuePlayer
+  return {
+    time: queuePlayer.time,
+    isLooping: player.isLooping,
+    isPaused: player.isPaused,
+    volume: player.volume
+  }
+}
+
+function saveBackend(backend) {
+  return {
+    queuePlayers: backend.queuePlayers.map(QP => ({
+      id: QP.id,
+      playingTrack: saveItemReference(QP.playingTrack),
+      queuedTracks: QP.queueGrouplike.items.map(saveItemReference),
+      pauseNextTrack: QP.pauseNextTrack,
+      playerInfo: getPlayerInfo(QP)
+    }))
+  }
+}
+
+async function restoreBackend(backend, data) {
+  if (data.queuePlayers) {
+    if (data.queuePlayers.length === 0) {
+      return
+    }
+
+    for (const qpData of data.queuePlayers) {
+      const QP = await backend.addQueuePlayer()
+      QP[referenceDataSymbol] = qpData
+
+      QP.id = qpData.id
+
+      QP.queueGrouplike.items = qpData.queuedTracks.map(refData => restoreNewItem(refData))
+
+      QP.player.setVolume(qpData.playerInfo.volume)
+      QP.player.setLoop(qpData.playerInfo.isLooping)
+
+      QP.on('playing', () => {
+        QP[referenceDataSymbol].playingTrack = null
+        QP[referenceDataSymbol].playerInfo = null
+      })
+    }
+
+    // We remove the old queue players after the new ones have been added,
+    // because the backend won't let us ever have less than one queue player
+    // at a time.
+    while (backend.queuePlayers.length !== data.queuePlayers.length) {
+      backend.removeQueuePlayer(backend.queuePlayers[0])
+    }
+  }
+}
+
+async function restorePlayingTrack(queuePlayer, playedTrack, playerInfo) {
+  const QP = queuePlayer
+  await QP.stopPlaying()
+  QP.play(playedTrack, true)
+  QP.once('received time data', () => {
+    if (QP.playingTrack === playedTrack) {
+      QP.player.seekTo(playerInfo.time)
+      if (!playerInfo.isPaused) {
+        QP.player.togglePause()
+      }
+    }
+  })
+}
+
+function updateRestoredTracksUsingPlaylists(backend, playlists) {
+  // Utility function to restore the "identities" of tracks (i.e. which objects
+  // they are represented by) queued or playing in the provided backend,
+  // pulling possible track identities from the provided 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 findItemObject, combining all provided
+  // playlists (simply putting them all in a group) to allow the algorithm to
+  // choose from all playlists equally at once.
+  //
+  // This function should be called after restoring a playlist and whenever
+  // a new source playlist is added (a new tab opened, etc).
+  //
+  // TODO: Though this helps to combat issues with restoring track identities
+  // when restoring from a saved backend, it could be expanded to restore from
+  // closed sources as well (reference data would have to be automatically
+  // saved on the tracks independently of save/restore in order to support
+  // this sort of functionality). Note this would still face difficulties with
+  // opening two identical playlists (i.e. the same playlist twice), since then
+  // identities would be equally correctly picked from either source; this is
+  // an inevitable issue with the way identities are resolved, but could be
+  // 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 possibleChoices = getFlatTrackList({items: playlists})
+
+  for (const QP of backend.queuePlayers) {
+    let playingDataToRestore
+
+    const qpData = (QP[referenceDataSymbol] || {})
+    const waitingTrackData = qpData.playingTrack
+    if (waitingTrackData) {
+      playingDataToRestore = waitingTrackData
+    } else if (QP.playingTrack) {
+      playingDataToRestore = QP.playingTrack[referenceDataSymbol]
+    }
+
+    if (playingDataToRestore) {
+      const found = findItemObject(playingDataToRestore, possibleChoices)
+      if (found) {
+        restorePlayingTrack(QP, found, qpData.playerInfo || getPlayerInfo(QP))
+      }
+    }
+
+    QP.queueGrouplike.items = QP.queueGrouplike.items.map(track => {
+      const refData = track[referenceDataSymbol]
+      if (!refData) {
+        return track
+      }
+
+      return findItemObject(refData, possibleChoices) || track
+    })
+
+    QP.emit('queue updated')
+  }
+}
+
+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
+  }
+}
+
+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
+    }
+  }
+}
+
+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
+  // be used to reflect that data in the user interface.
+
+  return (queuePlayer[referenceDataSymbol] || {}).playingTrack
+}
+
+Object.assign(module.exports, {
+  saveBackend,
+  restoreBackend,
+  updateRestoredTracksUsingPlaylists,
+  saveItemReference,
+  restoreNewItem,
+  getWaitingTrackData
+})
diff --git a/socket.js b/socket.js
new file mode 100644
index 0000000..fec66d7
--- /dev/null
+++ b/socket.js
@@ -0,0 +1,720 @@
+// Tools for hosting an MTUI party over a socket server. Comparable in idea to
+// telnet.js, but for interfacing over commands rather than hosting all client
+// UIs on one server. The intent of the code in this file is to allow clients
+// to connect and interface with each other, while still running all processes
+// involved in mtui on their own machines -- so mtui will download and play
+// music using each connected machine's own internet connection and speakers.
+
+// TODO: Option to display listing items which aren't available on all
+// connected devices.
+
+'use strict' // single quotes & no semicolons time babey
+
+// This is expected to be the same across both the client and the server.
+// There will probably be inconsistencies between sender clients and receiving
+// clients / the server otherwise.
+const DEFAULT_NICKNAME = '(Unnamed)'
+
+const EventEmitter = require('events')
+const net = require('net')
+
+const {
+  saveBackend,
+  restoreBackend,
+  saveItemReference,
+  restoreNewItem,
+  updateRestoredTracksUsingPlaylists
+} = require('./serialized-backend')
+
+const {
+  getTimeStringsFromSec,
+  silenceEvents
+} = require('./general-util')
+
+function serializeCommandToData(command) {
+  // Turn a command into a string/buffer that can be sent over a socket.
+  return JSON.stringify(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)
+}
+
+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.
+
+  if (typeof command !== 'object') {
+    return false
+  }
+
+  if (!['server', 'client'].includes(command.sender)) {
+    return false
+  }
+
+  switch (command.sender) {
+    case 'server':
+      switch (command.code) {
+        case 'initialize-backend':
+          return typeof command.backend === 'object'
+      }
+      // No break here; servers can send commands which typically come from
+      // 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.track)
+          )
+        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 'play':
+          return (
+            typeof command.queuePlayer === 'string' &&
+            isItemRef(command.track)
+          )
+        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)) &&
+            ['shuffle'].includes(command.why)
+          )
+        case 'seek-to':
+          return (
+            typeof command.queuePlayer === 'string' &&
+            typeof command.time === 'number'
+          )
+        case 'set-nickname':
+          return (
+            typeof command.nickname === 'string' &&
+            typeof command.oldNickname === 'string' &&
+            command.nickname.length >= 1 &&
+            command.nickname.length <= 12
+          )
+        case 'set-pause':
+          return (
+            typeof command.queuePlayer === 'string' &&
+            typeof command.paused === 'boolean' &&
+            (
+              typeof command.startingTrack === 'boolean' &&
+              command.sender === 'server'
+            ) || !command.startingTrack
+          )
+        case 'status':
+          return (
+            command.status === 'done-playing' ||
+            (
+              command.status === 'ready-to-resume' &&
+              typeof command.queuePlayer === 'string'
+            ) ||
+            command.status === 'sync-playback'
+          )
+        case 'stop-playing':
+          return typeof command.queuePlayer === 'string'
+        case 'unqueue':
+          return (
+            typeof command.queuePlayer === 'string' &&
+            isItemRef(command.topItem)
+          )
+      }
+      break
+  }
+
+  return false
+}
+
+function perLine(handleLine) {
+  // Wrapper function to run a callback for each line provided to the wrapped
+  // callback. Maintains a "partial" variable so that a line may be broken up
+  // into multiple chunks before it is sent. Also supports handling multiple
+  // lines (including the conclusion to a previously received partial line)
+  // being received at once.
+
+  let partial = ''
+  return data => {
+    const text = data.toString()
+    const lines = text.split('\n')
+    if (lines.length === 1) {
+      partial += text
+    } else {
+      handleLine(partial + lines[0])
+      for (const line of lines.slice(1, -1)) {
+        handleLine(line)
+      }
+      partial = lines[lines.length - 1]
+    }
+  }
+}
+
+function makeSocketServer() {
+  // The socket server has two functions: to maintain a "canonical" backend
+  // and synchronize newly connected clients with the relevent data in this
+  // backend, and to receive command data from clients and relay this to
+  // other clients.
+  //
+  // makeSocketServer doesn't actually start the server listening on a port;
+  // that's the responsibility of the caller (use server.listen()).
+
+  const server = new net.Server()
+  const sockets = []
+
+  server.canonicalBackend = null
+
+  // <variable> -> queue player id -> array: socket
+  const readyToResume = {}
+  const donePlaying = {}
+
+  server.on('connection', socket => {
+    sockets.push(socket)
+
+    let nickname = DEFAULT_NICKNAME
+
+    socket.on('close', () => {
+      if (sockets.includes(socket)) {
+        sockets.splice(sockets.indexOf(socket), 1)
+      }
+    })
+
+    socket.on('data', perLine(line => {
+      // Parse data as a command and validate it. If invalid, drop this data.
+
+      let command
+      try {
+        command = deserializeDataToCommand(line)
+      } catch (error) {
+        return
+      }
+
+      command.sender = 'client'
+      command.senderNickname = nickname
+
+      if (!validateCommand(command)) {
+        return
+      }
+
+      // If it's a status command, respond appropriately, and return so that it
+      // is not relayed.
+
+      if (command.code === 'status') {
+        switch (command.status) {
+          case 'done-playing': {
+            const doneSockets = donePlaying[command.queuePlayer]
+            if (doneSockets && !doneSockets.includes(socket)) {
+              doneSockets.push(socket)
+              if (doneSockets.length === sockets.length) {
+                // determine next track
+                for (const socket of sockets) {
+                  // play next track
+                }
+                delete donePlaying[command.queuePlayer]
+              }
+            }
+          }
+          case 'ready-to-resume': {
+            const readySockets = readyToResume[command.queuePlayer]
+            if (readySockets && !readySockets.includes(socket)) {
+              readySockets.push(socket)
+              if (readySockets.length === sockets.length) {
+                for (const socket of sockets) {
+                  socket.write(serializeCommandToData({
+                    sender: 'server',
+                    code: 'set-pause',
+                    queuePlayer: command.queuePlayer,
+                    startingTrack: true,
+                    paused: false
+                  }) + '\n')
+                  donePlaying[command.queuePlayer] = []
+                }
+                delete readyToResume[command.queuePlayer]
+              }
+            }
+            break
+          }
+          case 'sync-playback':
+            for (const QP of server.canonicalBackend.queuePlayers) {
+              if (QP.timeData) {
+                socket.write(serializeCommandToData({
+                  sender: 'server',
+                  code: 'seek-to',
+                  queuePlayer: QP.id,
+                  time: QP.timeData.curSecTotal
+                }) + '\n')
+                socket.write(serializeCommandToData({
+                  sender: 'server',
+                  code: 'set-pause',
+                  queuePlayer: QP.id,
+                  startingTrack: true,
+                  paused: QP.player.isPaused
+                }) + '\n')
+              }
+            }
+            break
+        }
+        return
+      }
+
+      // If it's a 'play' command, set up a new readyToResume array.
+
+      if (command.code === 'play') {
+        readyToResume[command.queuePlayer] = []
+      }
+
+      // If it's a 'set-nickname' command, save the nickname.
+      // Also attach the old nickname for display in log messages.
+
+      if (command.code === 'set-nickname') {
+        command.oldNickname = nickname
+        command.senderNickname = nickname
+        nickname = command.nickname
+      }
+
+      // Relay the command to client sockets besides the sender.
+
+      const otherSockets = sockets.filter(s => s !== socket)
+
+      for (const socket of otherSockets) {
+        socket.write(serializeCommandToData(command) + '\n')
+      }
+    }))
+
+    const savedBackend = saveBackend(server.canonicalBackend)
+
+    for (const qpData of savedBackend.queuePlayers) {
+      if (qpData.playerInfo) {
+        qpData.playerInfo.isPaused = true
+      }
+    }
+
+    socket.write(serializeCommandToData({
+      sender: 'server',
+      code: 'initialize-backend',
+      backend: savedBackend
+    }) + '\n')
+  })
+
+  return server
+}
+
+function makeSocketClient() {
+  // The socket client connects to a server and sends/receives commands to/from
+  // that server. This doesn't actually connect the socket to a port/host; that
+  // is the caller's responsibility (use client.socket.connect()).
+
+  const client = new EventEmitter()
+  client.socket = new net.Socket()
+  client.nickname = DEFAULT_NICKNAME
+
+  client.sendCommand = function(command) {
+    const data = serializeCommandToData(command)
+    client.socket.write(data + '\n')
+    client.emit('sent-command', command)
+  }
+
+  client.setNickname = function(nickname) {
+    let oldNickname = client.nickname
+    client.nickname = nickname
+    client.sendCommand({code: 'set-nickname', nickname, oldNickname})
+  }
+
+  client.socket.on('data', perLine(line => {
+    // Same sort of "guarding" deserialization/validation as in the server
+    // code, because it's possible the client and server backends mismatch.
+
+    let command
+    try {
+      command = deserializeDataToCommand(line)
+    } catch (error) {
+      return
+    }
+
+    if (!validateCommand(command)) {
+      return
+    }
+
+    client.emit('command', command)
+  }))
+
+  return client
+}
+
+function attachBackendToSocketClient(backend, client, {
+  getPlaylistSources
+}) {
+  // All actual logic for instances of the mtui backend interacting with each
+  // other through commands lives here.
+
+  backend.setAlwaysStartPaused(true)
+  backend.setWaitWhenDonePlaying(true)
+
+  function logCommand(command) {
+    const nickToMessage = nickname => `\x1b[32;1m${nickname}\x1b[0m`
+    const itemToMessage = item => `\x1b[32m"${item.name}"\x1b[0m`
+    let fullmsg = '' // may be overridden
+    let actionmsg = `sent ${command.code}` // fallback
+    switch (command.code) {
+      case 'clear-queue':
+        actionmsg = 'cleared the queue'
+        break
+      case 'clear-queue-past':
+        actionmsg = `cleared the queue past ${itemToMessage(command.track)}`
+        break
+      case 'clear-queue-up-to':
+        actionmsg = `cleared the queue up to ${itemToMessage(command.track)}`
+        break
+      case 'distribute-queue':
+        actionmsg = `distributed ${itemToMessage(command.topItem)} across the queue ${command.opts.how}`
+        break
+      case 'initialize-backend':
+        return
+      case 'play':
+        actionmsg = `started playing ${itemToMessage(command.track)}`
+        break
+      case 'queue': {
+        let afterMessage = ''
+        if (isItemRef(command.afterItem)) {
+          afterMessage = ` after ${itemToMessage(command.afterItem)}`
+        } else if (command.afterItem === 'FRONT') {
+          afterMessage = ` at the front of the queue`
+        }
+        actionmsg = `queued ${itemToMessage(command.topItem)}` + afterMessage
+        break
+      }
+      case 'restore-queue':
+        if (command.why === 'shuffle') {
+          actionmsg = 'shuffled the queue'
+        }
+        break
+      case 'seek-to':
+        // TODO: the second value here should be the duration of the track
+        // (this will make values like 0:0x:yy / 1:xx:yy appear correctly)
+        actionmsg = `seeked to ${getTimeStringsFromSec(command.time, command.time).timeDone}`
+        break
+      case 'set-nickname':
+        fullmsg = `${nickToMessage(command.nickname)} updated their nickname (from ${nickToMessage(command.oldNickname)})`
+        break
+      case 'set-pause':
+        if (command.paused) {
+          actionmsg = 'paused the player'
+        } else {
+          actionmsg = 'resumed the player'
+        }
+        break
+      case 'stop-playing':
+        actionmsg = 'stopped the player'
+        break
+      case 'unqueue':
+        actionmsg = `removed ${itemToMessage(command.topItem)} from the queue`
+        break
+      case 'status':
+        return
+    }
+    if (!fullmsg) {
+      const nickname = command.sender === 'server' ? 'the server' : command.senderNickname
+      fullmsg = `${nickToMessage(nickname)} ${actionmsg}`
+    }
+    backend.showLogMessage(fullmsg)
+  }
+
+  client.on('sent-command', command => {
+    command.senderNickname = client.nickname
+    logCommand(command)
+  })
+
+  client.on('command', async command => {
+    logCommand(command)
+    switch (command.sender) {
+      case 'server':
+        switch (command.code) {
+          case 'initialize-backend':
+            await restoreBackend(backend, command.backend)
+            // TODO: does this need to be called here?
+            updateRestoredTracksUsingPlaylists(backend, getPlaylistSources())
+            backend.on('playing', QP => {
+              QP.once('received time data', () => {
+                client.sendCommand({code: 'status', status: 'sync-playback'})
+              })
+            })
+            return
+        }
+        // Again, no pause. Client commands can come from the server.
+      case 'client': {
+        let QP = (
+          command.queuePlayer &&
+          backend.queuePlayers.find(QP => QP.id === command.queuePlayer)
+        )
+
+        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.track, getPlaylistSources())
+            ))
+            return
+          case 'clear-queue-up-to':
+            if (QP) silenceEvents(QP, ['clear-queue-up-to'], () => QP.clearQueueUpTo(
+              restoreNewItem(command.track, getPlaylistSources())
+            ))
+            return
+          case 'distribute-queue':
+            if (QP) silenceEvents(QP, ['distribute-queue'], () => QP.distributeQueue(
+              restoreNewItem(command.topItem),
+              {
+                how: command.opts.how,
+                rangeEnd: command.opts.rangeEnd
+              }
+            ))
+            return
+          case 'play':
+            if (QP) {
+              QP.once('received time data', data => {
+                client.sendCommand({
+                  code: 'status',
+                  status: 'ready-to-resume',
+                  queuePlayer: QP.id
+                })
+              })
+              silenceEvents(QP, ['playing'], () => QP.play(
+                restoreNewItem(command.track, getPlaylistSources())
+              ))
+            }
+            return
+          case 'queue':
+            if (QP) silenceEvents(QP, ['queue'], () => QP.queue(
+              restoreNewItem(command.topItem),
+              isItemRef(command.afterItem) ? restoreNewItem(command.afterItem, getPlaylistSources()) : command.afterItem,
+              {
+                movePlayingTrack: command.opts.movePlayingTrack
+              }
+            ))
+            return
+          case 'restore-queue':
+            if (QP) {
+              QP.replaceAllItems(command.tracks.map(
+                refData => restoreNewItem(refData, getPlaylistSources())
+              ))
+            }
+            return
+          case 'seek-to':
+            if (QP) silenceEvents(QP, ['seek-to'], () => QP.seekTo(command.time))
+            return
+          case 'set-pause': {
+            // TODO: there's an event leak here when toggling pause while
+            // nothing is playing
+            let playingThisTrack = true
+            QP.once('playing new track', () => {
+              playingThisTrack = false
+            })
+            setTimeout(() => {
+              if (playingThisTrack) {
+                if (QP) silenceEvents(QP, ['set-pause'], () => QP.setPause(command.paused))
+              }
+            }, command.startingTrack ? 500 : 0)
+            return
+          }
+          case 'stop-playing':
+            if (QP) silenceEvents(QP, ['playing'], () => QP.stopPlaying())
+            return
+          case 'unqueue':
+            if (QP) silenceEvents(QP, ['unqueue'], () => QP.unqueue(
+              restoreNewItem(command.topItem, getPlaylistSources())
+            ))
+            return
+        }
+      }
+    }
+  })
+
+  backend.on('clear-queue', queuePlayer => {
+    client.sendCommand({
+      code: 'clear-queue',
+      queuePlayer: queuePlayer.id
+    })
+  })
+
+  backend.on('clear-queue-past', (queuePlayer, track) => {
+    client.sendCommand({
+      code: 'clear-queue-past',
+      queuePlayer: queuePlayer.id,
+      track: saveItemReference(track)
+    })
+  })
+
+  backend.on('clear-queue-up-to', (queuePlayer, track) => {
+    client.sendCommand({
+      code: 'clear-queue-up-to',
+      queuePlayer: queuePlayer.id,
+      track: saveItemReference(track)
+    })
+  })
+
+  backend.on('distribute-queue', (queuePlayer, topItem, opts) => {
+    client.sendCommand({
+      code: 'distribute-queue',
+      queuePlayer: queuePlayer.id,
+      topItem: saveItemReference(topItem),
+      opts
+    })
+  })
+
+  backend.on('done playing', queuePlayer => {
+    client.sendCommand({
+      code: 'status',
+      status: 'done-playing',
+      queuePlayer: queuePlayer.id
+    })
+  })
+
+  backend.on('playing', (queuePlayer, track) => {
+    if (track) {
+      client.sendCommand({
+        code: 'play',
+        queuePlayer: queuePlayer.id,
+        track: saveItemReference(track)
+      })
+      queuePlayer.once('received time data', data => {
+        client.sendCommand({
+          code: 'status',
+          status: 'ready-to-resume',
+          queuePlayer: queuePlayer.id
+        })
+      })
+    } else {
+      client.sendCommand({
+        code: 'stop-playing',
+        queuePlayer: queuePlayer.id
+      })
+    }
+  })
+
+  backend.on('queue', (queuePlayer, topItem, afterItem, opts) => {
+    client.sendCommand({
+      code: 'queue',
+      queuePlayer: queuePlayer.id,
+      topItem: saveItemReference(topItem),
+      afterItem: saveItemReference(afterItem),
+      opts
+    })
+  })
+
+  function handleSeek(queuePlayer) {
+    client.sendCommand({
+      code: 'seek-to',
+      queuePlayer: queuePlayer.id,
+      time: queuePlayer.time
+    })
+  }
+
+  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',
+      why: 'shuffle',
+      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)
+    })
+  })
+}
+
+function attachSocketServerToBackend(server, backend) {
+  // Unlike the function for attaching a backend to follow commands from a
+  // client (attachBackendToSocketClient), this function is minimalistic.
+  // It just sets the associated "canonical" backend. Actual logic for
+  // de/serialization lives in serialized-backend.js.
+  server.canonicalBackend = backend
+}
+
+Object.assign(module.exports, {
+  makeSocketServer,
+  makeSocketClient,
+  attachBackendToSocketClient,
+  attachSocketServerToBackend
+})
diff --git a/ui.js b/ui.js
index de73a81..68cda91 100644
--- a/ui.js
+++ b/ui.js
@@ -32,6 +32,11 @@ const {
 } = require('./playlist-utils')
 
 const {
+  updateRestoredTracksUsingPlaylists,
+  getWaitingTrackData
+} = require('./serialized-backend')
+
+const {
   ui: {
     Dialog,
     DisplayElement,
@@ -54,7 +59,7 @@ const {
 } = require('tui-lib')
 
 /* text editor features disabled because theyre very much incomplete and havent
- * gotten much use from me or anyonea afaik!
+ * gotten much use from me or anyone afaik!
 const TuiTextEditor = require('tui-text-editor')
 */
 
@@ -191,6 +196,8 @@ class AppElement extends FocusElement {
     this.isPartyHost = false
     this.enableAutoDJ = false
 
+    this.playlistSources = []
+
     this.config = Object.assign({
       canControlPlayback: true,
       canControlQueue: true,
@@ -238,6 +245,18 @@ class AppElement extends FocusElement {
     })
     */
 
+    this.logPane = new Pane()
+    this.addChild(this.logPane)
+
+    this.log = new Log()
+    this.logPane.addChild(this.log)
+    this.logPane.visible = false
+
+    this.log.on('log-message', () => {
+      this.logPane.visible = true
+      this.fixLayout()
+    })
+
     if (!this.config.showTabberPane) {
       this.tabberPane.visible = false
     }
@@ -430,12 +449,13 @@ class AppElement extends FocusElement {
 
   bindListeners() {
     for (const key of [
-      'handlePlaying',
+      'handlePlayingDetails',
       'handleReceivedTimeData',
       'handleProcessMetadataProgress',
       'handleQueueUpdated',
       'handleAddedQueuePlayer',
-      'handleRemovedQueuePlayer'
+      'handleRemovedQueuePlayer',
+      'handleLogMessage'
     ]) {
       this[key] = this[key].bind(this)
     }
@@ -468,7 +488,7 @@ class AppElement extends FocusElement {
     PIE.on('toggle pause', () => PIE.queuePlayer.togglePause())
 
     queuePlayer.on('received time data', this.handleReceivedTimeData)
-    queuePlayer.on('playing', this.handlePlaying)
+    queuePlayer.on('playing details', this.handlePlayingDetails)
     queuePlayer.on('queue updated', this.handleQueueUpdated)
   }
 
@@ -497,7 +517,7 @@ class AppElement extends FocusElement {
     }
 
     queuePlayer.removeListener('receivedTimeData', this.handleReceivedTimeData)
-    queuePlayer.removeListener('playing', this.handlePlaying)
+    queuePlayer.removeListener('playing details', this.handlePlayingDetails)
     queuePlayer.removeListener('queue updated', this.handleQueueUpdated)
     queuePlayer.stopPlaying()
   }
@@ -506,12 +526,14 @@ class AppElement extends FocusElement {
     this.backend.on('processMetadata progress', this.handleProcessMetadataProgress)
     this.backend.on('added queue player', this.handleAddedQueuePlayer)
     this.backend.on('removed queue player', this.handleRemovedQueuePlayer)
+    this.backend.on('log message', this.handleLogMessage)
   }
 
   removeBackendListeners() {
     this.backend.removeListener('processMetadata progress', this.handleProcessMetadataProgress)
     this.backend.removeListener('added queue player', this.handleAddedQueuePlayer)
     this.backend.removeListener('removed queue player', this.handleRemovedQueuePlayer)
+    this.backend.removeListener('log message', this.handleLogMessage)
   }
 
   handleAddedQueuePlayer(queuePlayer) {
@@ -525,7 +547,11 @@ class AppElement extends FocusElement {
     }
   }
 
-  async handlePlaying(track, oldTrack, queuePlayer) {
+  handleLogMessage(text) {
+    this.log.newLogMessage(text)
+  }
+
+  async handlePlayingDetails(track, oldTrack, queuePlayer) {
     const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
     if (PIE) {
       PIE.updateTrack()
@@ -1209,6 +1235,9 @@ class AppElement extends FocusElement {
 
     grouplike = await processSmartPlaylist(grouplike)
 
+    this.playlistSources.push(grouplike)
+    updateRestoredTracksUsingPlaylists(this.backend, this.playlistSources)
+
     if (!this.tabber.currentElement || newTab && this.tabber.currentElement.grouplike) {
       const grouplikeListing = this.newGrouplikeListing()
       grouplikeListing.loadGrouplike(grouplike)
@@ -1322,10 +1351,21 @@ class AppElement extends FocusElement {
     }
     */
 
+    if (this.logPane.visible) {
+      this.logPane.w = leftWidth
+      this.logPane.h = 6
+      this.log.fillParent()
+      this.log.fixAllLayout()
+    }
+
     if (this.tabberPane.visible) {
       this.tabberPane.w = leftWidth
       this.tabberPane.y = bottomY
       this.tabberPane.h = topY - this.tabberPane.y
+      if (this.logPane.visible) {
+        this.tabberPane.h -= this.logPane.h
+        this.logPane.y = this.tabberPane.bottom
+      }
       /*
       if (this.textInfoPane.visible) {
         this.tabberPane.h -= this.textInfoPane.h
@@ -3517,6 +3557,7 @@ class PlaybackInfoElement extends FocusElement {
 
   refreshTrackText(maxNameWidth = Infinity) {
     const { playingTrack } = this.queuePlayer
+    const waitingTrackData = getWaitingTrackData(this.queuePlayer)
     if (playingTrack) {
       this.currentTrack = playingTrack
       const { name } = playingTrack
@@ -3528,6 +3569,11 @@ class PlaybackInfoElement extends FocusElement {
       this.progressBarLabel.text = ''
       this.progressTextLabel.text = '(Starting..)'
       this.timeData = {}
+    } else if (waitingTrackData) {
+      const { name } = waitingTrackData
+      this.clearInfoText()
+      this.trackNameLabel.text = name
+      this.progressTextLabel.text = '(Waiting to play, once found in playlist source.)'
     } else {
       this.clearInfoText()
     }
@@ -4540,4 +4586,68 @@ class NotesTextEditor extends TuiTextEditor {
 }
 */
 
+class Log extends ListScrollForm {
+  constructor() {
+    super('vertical')
+  }
+
+  newLogMessage(text) {
+    if (this.inputs.length === 10) {
+      this.removeInput(this.inputs[0])
+    }
+
+    const logMessage = new LogMessage(text)
+    this.addInput(logMessage)
+    this.fixLayout()
+    this.scrollToEnd()
+    this.emit('log-message', logMessage)
+    return logMessage
+  }
+}
+
+class LogMessage extends FocusElement {
+  constructor(text) {
+    super()
+
+    this.label = new LogMessageLabel(text)
+    this.addChild(this.label)
+  }
+
+  fixLayout() {
+    this.w = this.parent.contentW
+    this.label.w = this.contentW
+    this.h = this.label.h
+  }
+
+  clicked(button) {
+    if (button === 'left') {
+      this.root.select(this)
+      return false
+    }
+  }
+}
+
+class LogMessageLabel extends WrapLabel {
+  writeTextTo(writable) {
+    const w = this.w
+    const lines = this.getWrappedLines()
+    for (let i = 0; i < lines.length; i++) {
+      const text = this.processFormatting(lines[i])
+      writable.write(ansi.moveCursor(this.absTop + i, this.absLeft))
+      writable.write(text)
+      const width = ansi.measureColumns(text)
+      if (width < w && this.textAttributes.length) {
+        writable.write(ansi.setAttributes([ansi.A_RESET, ...this.textAttributes]))
+        writable.write(' '.repeat(w - width))
+      }
+    }
+  }
+
+  set textAttributes(val) {}
+
+  get textAttributes() {
+    return this.parent.isSelected ? [40] : []
+  }
+}
+
 module.exports = AppElement