« 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--.eslintrc24
-rw-r--r--README.md2
-rw-r--r--backend.js291
-rw-r--r--client.js46
-rw-r--r--combine-album.js223
-rw-r--r--crawlers.js65
-rw-r--r--downloaders.js94
-rw-r--r--general-util.js157
-rw-r--r--guess.js24
-rwxr-xr-xindex.js181
-rw-r--r--metadata-readers.js29
-rw-r--r--package-lock.json1362
-rw-r--r--package.json11
-rw-r--r--players.js506
-rw-r--r--playlist-utils.js192
-rw-r--r--record-store.js2
-rw-r--r--serialized-backend.js37
-rw-r--r--smart-playlist.js8
-rw-r--r--socat.js18
-rw-r--r--socket.js600
-rw-r--r--telnet.js23
-rw-r--r--todo.txt172
-rw-r--r--ui.js1268
-rw-r--r--undo-manager.js4
24 files changed, 4072 insertions, 1267 deletions
diff --git a/.eslintrc b/.eslintrc
new file mode 100644
index 0000000..f742bb8
--- /dev/null
+++ b/.eslintrc
@@ -0,0 +1,24 @@
+{
+  "env": {
+    "es2021": true,
+    "node": true
+  },
+  "extends": "eslint:recommended",
+  "parserOptions": {
+    "ecmaVersion": "latest",
+    "sourceType": "module"
+  },
+  "rules": {
+    "indent": ["off"],
+    "no-constant-condition": ["error", {
+      "checkLoops": false
+    }],
+    "no-empty": ["error", {
+      "allowEmptyCatch": true
+    }],
+    "no-unused-vars": ["error", {
+      "argsIgnorePattern": "^_",
+      "destructuredArrayIgnorePattern": "^"
+    }]
+  }
+}
diff --git a/README.md b/README.md
index 40c4d95..3efa0df 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ You're also welcome to share any ideas, suggestions, and questions through there
 * [: focus the main track/group listing
 * ]: focus the queue listing
 * Enter: play the selected track
-* Ctrl+Up, p: play previous track
+* Ctrl+Up, p: play previous track or seek to start of current track
 * Ctrl+Down, n: play next track
 * o: open the selected item through the system
 * Shift+Up/Down or drag: select multiple items at once
diff --git a/backend.js b/backend.js
index 3d9c386..a491f00 100644
--- a/backend.js
+++ b/backend.js
@@ -3,32 +3,29 @@
 
 'use strict'
 
-const { getDownloaderFor } = require('./downloaders')
-const { getMetadataReaderFor } = require('./metadata-readers')
-const { getPlayer } = require('./players')
-const RecordStore = require('./record-store')
-const os = require('os')
-const shortid = require('shortid')
-
-const {
+import {readFile, writeFile} from 'node:fs/promises'
+import EventEmitter from 'node:events'
+import os from 'node:os'
+
+import shortid from 'shortid'
+
+import {getDownloaderFor} from './downloaders.js'
+import {getMetadataReaderFor} from './metadata-readers.js'
+import {getPlayer, GhostPlayer} from './players.js'
+import RecordStore from './record-store.js'
+
+import {
   getTimeStringsFromSec,
   shuffleArray,
-  throttlePromise
-} = require('./general-util')
+  throttlePromise,
+} from './general-util.js'
 
-const {
+import {
   isGroup,
   isTrack,
   flattenGrouplike,
-  getItemPathString,
-  parentSymbol
-} = require('./playlist-utils')
-
-const { promisify } = require('util')
-const EventEmitter = require('events')
-const fs = require('fs')
-const writeFile = promisify(fs.writeFile)
-const readFile = promisify(fs.readFile)
+  parentSymbol,
+} from './playlist-utils.js'
 
 async function download(item, record) {
   if (isGroup(item)) {
@@ -69,7 +66,7 @@ class QueuePlayer extends EventEmitter {
     this.playingTrack = null
     this.queueGrouplike = {name: 'Queue', isTheQueue: true, items: []}
     this.pauseNextTrack = false
-    this.loopQueueAtEnd = false
+    this.queueEndMode = 'end' // end, loop, shuffle
     this.playedTrackToEnd = false
     this.timeData = null
     this.time = null
@@ -92,9 +89,10 @@ class QueuePlayer extends EventEmitter {
 
     this.player.on('printStatusLine', data => {
       if (this.playingTrack) {
+        const oldTimeData = this.timeData
         this.timeData = data
         this.time = data.curSecTotal
-        this.emit('received time data', data, this)
+        this.emit('received time data', data, oldTimeData)
       }
     })
 
@@ -217,20 +215,6 @@ class QueuePlayer extends EventEmitter {
 
     const distributeSize = distributeEnd - distributeStart
 
-    const queueItem = (item, insertIndex) => {
-      if (items.includes(item)) {
-        /*
-        if (!movePlayingTrack && item === this.playingTrack) {
-          return
-        }
-        */
-        items.splice(items.indexOf(item), 1)
-      } else {
-        offset++
-      }
-      items.splice(insertIndex, 0, item)
-    }
-
     if (how === 'evenly') {
       let offset = 0
       for (const item of newTracks) {
@@ -249,7 +233,7 @@ class QueuePlayer extends EventEmitter {
       }
     }
 
-    this.emit('distribute-queue', topItem, {how, rangeEnd})
+    this.emit('distribute queue', topItem, {how, rangeEnd})
     this.emitQueueUpdated()
   }
 
@@ -317,7 +301,7 @@ class QueuePlayer extends EventEmitter {
       items.splice(index)
     }
 
-    this.emit('clear-queue-past', track)
+    this.emit('clear queue past', track)
     this.emitQueueUpdated()
   }
 
@@ -334,7 +318,7 @@ class QueuePlayer extends EventEmitter {
       items.splice(startIndex, endIndex - startIndex)
     }
 
-    this.emit('clear-queue-up-to', track)
+    this.emit('clear queue up to', track)
     this.emitQueueUpdated()
   }
 
@@ -358,14 +342,16 @@ class QueuePlayer extends EventEmitter {
     }
   }
 
-  shuffleQueue() {
+  shuffleQueue(pastPlayingTrackOnly = true) {
     const queue = this.queueGrouplike
-    const index = queue.items.indexOf(this.playingTrack) + 1 // This is 0 if no track is playing
+    const index = (pastPlayingTrackOnly
+      ? queue.items.indexOf(this.playingTrack) + 1 // This is 0 if no track is playing
+      : 0)
     const initialItems = queue.items.slice(0, index)
     const remainingItems = queue.items.slice(index)
     const newItems = initialItems.concat(shuffleArray(remainingItems))
     queue.items = newItems
-    this.emit('shuffle-queue')
+    this.emit('shuffle queue')
     this.emitQueueUpdated()
   }
 
@@ -374,7 +360,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.emit('clear queue')
     this.emitQueueUpdated()
   }
 
@@ -390,18 +376,11 @@ class QueuePlayer extends EventEmitter {
     this.clearPlayingTrack()
   }
 
-
-  async play(item, forceStartPaused) {
+  async play(item, startTime = 0, forceStartPaused = false) {
     if (this.player === null) {
       throw new Error('Attempted to play before a player was loaded')
     }
 
-    let playingThisTrack = true
-    this.emit('playing new track')
-    this.once('playing new track', () => {
-      playingThisTrack = false
-    })
-
     // If it's a group, play the first track.
     if (isGroup(item)) {
       item = flattenGrouplike(item).items[0]
@@ -417,13 +396,18 @@ class QueuePlayer extends EventEmitter {
       return
     }
 
-    playTrack: {
+    let playingThisTrack = true
+    this.emit('playing new track')
+    this.once('playing new track', () => {
+      playingThisTrack = false
+    })
+
+    if (this.player instanceof GhostPlayer) {
+      await this.#ghostPlay(item, startTime)
+    } else if (!item.downloaderArg) {
       // No downloader argument? That's no good - stop here.
       // TODO: An error icon on this item, or something???
-      if (!item.downloaderArg) {
-        break playTrack
-      }
-
+    } else {
       // If, by the time the track is downloaded, we're playing something
       // different from when the download started, assume that we just want to
       // keep listening to whatever new thing we started.
@@ -439,8 +423,8 @@ 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)
+      this.emit('playing details', this.playingTrack, oldTrack, startTime)
+      this.emit('playing', this.playingTrack)
 
       await this.player.kill()
       if (this.alwaysStartPaused || forceStartPaused) {
@@ -452,7 +436,7 @@ class QueuePlayer extends EventEmitter {
       } else {
         this.player.setPause(false)
       }
-      await this.player.playFile(downloadFile)
+      await this.player.playFile(downloadFile, startTime)
     }
 
     // playingThisTrack now means whether the track played through to the end
@@ -462,20 +446,31 @@ class QueuePlayer extends EventEmitter {
       this.playedTrackToEnd = true
       this.emit('done playing', this.playingTrack)
       if (!this.waitWhenDonePlaying) {
-        if (!this.playNext(item)) {
-          if (this.loopQueueAtEnd) {
-            this.playFirst()
-          } else {
-            this.clearPlayingTrack()
-          }
-        }
+        this.playNext(item)
       }
     }
   }
 
+  async #ghostPlay(item, startTime) {
+    // If we're playing off a GhostPlayer, strip down the whole process.
+    // Downloading is totally unnecessary, for example.
+
+    this.timeData = null
+    this.time = null
+    this.playingTrack = item
+    this.emit('playing', this.playingTrack)
+    await this.player.playFile('-', startTime)
+  }
+
   playNext(track, automaticallyQueueNextTrack = false) {
     if (!track) return false
 
+    // Auto-queue is nice but it should only happen when the queue hasn't been
+    // explicitly set to loop.
+    automaticallyQueueNextTrack = (
+      automaticallyQueueNextTrack &&
+      this.queueEndMode === 'end')
+
     const queue = this.queueGrouplike
     let queueIndex = queue.items.indexOf(track)
     if (queueIndex === -1) return false
@@ -494,7 +489,7 @@ class QueuePlayer extends EventEmitter {
         this.queue(nextItem)
         queueIndex = queue.items.length - 1
       } else {
-        return false
+        return this.playNextAtQueueEnd()
       }
     }
 
@@ -540,14 +535,51 @@ class QueuePlayer extends EventEmitter {
     return false
   }
 
+  playNextAtQueueEnd() {
+    switch (this.queueEndMode) {
+      case 'loop':
+        this.playFirst()
+        return true
+      case 'shuffle':
+        this.shuffleQueue(false)
+        this.playFirst()
+        return true
+      case 'end':
+      default:
+        this.clearPlayingTrack()
+        return false
+    }
+  }
+
+  async playOrSeek(item, time) {
+    if (!isTrack(item)) {
+      // This only makes sense to call with individual tracks!
+      return
+    }
+
+    if (item === this.playingTrack) {
+      this.seekTo(time)
+    } else {
+      // Queue the track, but only if it's not already in the queue, so that we
+      // respect an existing queue order.
+      const queue = this.queueGrouplike
+      const queueIndex = queue.items.indexOf(item)
+      if (queueIndex === -1) {
+        this.queue(item, this.playingTrack)
+      }
+
+      this.play(item, time)
+    }
+  }
+
   clearPlayingTrack() {
     if (this.playingTrack !== null) {
       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)
+      this.emit('playing details', null, oldTrack, 0)
+      this.emit('playing', null)
     }
   }
 
@@ -558,7 +590,7 @@ class QueuePlayer extends EventEmitter {
   seekAhead(seconds) {
     this.time += seconds
     this.player.seekAhead(seconds)
-    this.emit('seek-ahead', +seconds)
+    this.emit('seek ahead', +seconds)
   }
 
   seekBack(seconds) {
@@ -568,48 +600,56 @@ class QueuePlayer extends EventEmitter {
       this.time -= seconds
     }
     this.player.seekBack(seconds)
-    this.emit('seek-back', +seconds)
+    this.emit('seek back', +seconds)
   }
 
   seekTo(timeInSecs) {
     this.time = timeInSecs
     this.player.seekTo(timeInSecs)
-    this.emit('seek-to', +timeInSecs)
+    this.emit('seek to', +timeInSecs)
+  }
+
+  seekTo(seconds) {
+    this.player.seekTo(seconds)
+  }
+
+  seekToStart() {
+    this.player.seekToStart()
   }
 
   togglePause() {
     this.player.togglePause()
-    this.emit('toggle-pause')
+    this.emit('toggle pause')
   }
 
   setPause(value) {
     this.player.setPause(value)
-    this.emit('set-pause', !!value)
+    this.emit('set pause', !!value)
   }
 
   toggleLoop() {
     this.player.toggleLoop()
-    this.emit('toggle-loop')
+    this.emit('toggle loop')
   }
 
   setLoop(value) {
     this.player.setLoop(value)
-    this.emit('set-loop', !!value)
+    this.emit('set loop', !!value)
   }
 
-  volUp(amount = 10) {
+  volumeUp(amount = 10) {
     this.player.volUp(amount)
-    this.emit('vol-up', +amount)
+    this.emit('volume up', +amount)
   }
 
-  volDown(amount = 10) {
+  volumeDown(amount = 10) {
     this.player.volDown(amount)
-    this.emit('vol-down', +amount)
+    this.emit('volume down', +amount)
   }
 
   setVolume(value) {
     this.player.setVolume(value)
-    this.emit('set-volume', +value)
+    this.emit('set volume', +value)
   }
 
   setVolumeMultiplier(value) {
@@ -622,12 +662,18 @@ class QueuePlayer extends EventEmitter {
 
   setPauseNextTrack(value) {
     this.pauseNextTrack = !!value
-    this.emit('set-pause-next-track', !!value)
+    this.emit('set pause next track', !!value)
   }
 
   setLoopQueueAtEnd(value) {
     this.loopQueueAtEnd = !!value
-    this.emit('set-loop-queue-at-end', !!value)
+    this.emit('set loop queue at end', !!value)
+  }
+
+  setDuration(duration) {
+    if (this.player.setDuration) {
+      setTimeout(() => this.player.setDuration(duration))
+    }
   }
 
   get remainingTracks() {
@@ -653,7 +699,7 @@ class QueuePlayer extends EventEmitter {
   }
 }
 
-class Backend extends EventEmitter {
+export default class Backend extends EventEmitter {
   constructor({
     playerName = null,
     playerOptions = []
@@ -672,6 +718,12 @@ class Backend extends EventEmitter {
     this.waitWhenDonePlaying = false
 
     this.hasAnnouncedJoin = false
+    this.sharedSourcesMap = Object.create(null)
+    this.sharedSourcesGrouplike = {
+      name: 'Shared Sources',
+      isPartySources: true,
+      items: []
+    }
 
     this.recordStore = new RecordStore()
     this.throttleMetadata = throttlePromise(10)
@@ -710,33 +762,43 @@ class Backend extends EventEmitter {
     this.emit('added queue player', queuePlayer)
 
     for (const event of [
-      'playing',
+      'clear queue',
+      'clear queue past',
+      'clear queue up to',
+      'distribute queue',
       'done playing',
+      'playing',
+      'playing details',
       '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',
-      'set-loop-queue-at-end'
+      'received time data',
+      'seek ahead',
+      'seek back',
+      'seek to',
+      'set loop',
+      'set loop queue at end',
+      'set pause',
+      'set pause next track',
+      'set volume',
+      'shuffle queue',
+      'toggle loop',
+      'toggle pause',
+      'unqueue',
+      'volume down',
+      'volume up',
     ]) {
       queuePlayer.on(event, (...data) => {
-        this.emit(event, queuePlayer, ...data)
+        this.emit('QP: ' + event, queuePlayer, ...data)
       })
     }
 
+    queuePlayer.on('playing', track => {
+      if (track) {
+        const metadata = this.getMetadataFor(track)
+        queuePlayer.setDuration(metadata?.duration)
+      }
+    })
+
     return queuePlayer
   }
 
@@ -873,17 +935,24 @@ class Backend extends EventEmitter {
     this.hasAnnouncedJoin = hasAnnouncedJoin
   }
 
-  loadPartyGrouplike(socketId, partyGrouplike) {
-    this.emit('got party grouplike', socketId, partyGrouplike)
+  loadSharedSources(socketId, sharedSources) {
+    if (socketId in this.sharedSourcesMap) {
+      return
+    }
+
+    this.sharedSourcesMap[socketId] = sharedSources
+
+    sharedSources[parentSymbol] = this.sharedSourcesGrouplike
+    this.sharedSourcesGrouplike.items.push(sharedSources)
+
+    this.emit('got shared sources', socketId, sharedSources)
   }
 
-  shareWithParty(item) {
-    this.emit('share with party', item)
+  sharedSourcesUpdated(socketId, sharedSources) {
+    this.emit('shared sources updated', socketId, sharedSources)
   }
 
-  partyGrouplikeUpdated(socketId, partyGrouplike) {
-    this.emit('party grouplike updated', socketId, partyGrouplike)
+  shareWithParty(item) {
+    this.emit('share with party', item)
   }
 }
-
-module.exports = Backend
diff --git a/client.js b/client.js
index aa854ed..0af45f6 100644
--- a/client.js
+++ b/client.js
@@ -1,23 +1,18 @@
 // Generic code for setting up mtui and the UI for any command line client.
 
-'use strict'
-
-const AppElement = require('./ui')
-const processSmartPlaylist = require('./smart-playlist')
-const os = require('os')
-
-const {
-  ui: {
-    Root
-  },
-  util: {
-    ansi,
-    Flushable,
-    TelnetInterfacer
-  }
-} = require('tui-lib')
+import AppElement from './ui.js'
+
+import {Root} from 'tui-lib/ui/primitives'
+
+import {Flushable} from 'tui-lib/util/interfaces'
+import * as ansi from 'tui-lib/util/ansi'
 
-const setupClient = async ({backend, writable, interfacer, appConfig}) => {
+export default async function setupClient({
+  backend,
+  writable,
+  screenInterface,
+  appConfig,
+}) {
   const cleanTerminal = () => {
     writable.write(ansi.cleanCursor())
     writable.write(ansi.disableAlternateScreen())
@@ -31,10 +26,10 @@ const setupClient = async ({backend, writable, interfacer, appConfig}) => {
   dirtyTerminal()
 
   const flushable = new Flushable(writable, true)
-  const root = new Root(interfacer, flushable)
+  const root = new Root(screenInterface, flushable)
   root.on('rendered', () => flushable.flush())
 
-  const size = await interfacer.getScreenSize()
+  const size = await screenInterface.getScreenSize()
   root.w = size.width
   root.h = size.height
   root.fixAllLayout()
@@ -43,7 +38,7 @@ const setupClient = async ({backend, writable, interfacer, appConfig}) => {
   flushable.write(ansi.clearScreen())
   flushable.flush()
 
-  interfacer.on('resize', newSize => {
+  screenInterface.on('resize', newSize => {
     root.w = newSize.width
     root.h = newSize.height
     flushable.resizeScreen(newSize)
@@ -63,15 +58,6 @@ const setupClient = async ({backend, writable, interfacer, appConfig}) => {
     cleanTerminal()
   })
 
-  let grouplike = {
-    name: 'My ~/Music Library',
-    comment: (
-      '(Add tracks and folders to ~/Music to make them show up here,' +
-      ' or pass mtui your own playlist.json file!)'),
-    source: ['crawl-local', os.homedir() + '/Music']
-  }
-  await appElement.loadPlaylistOrSource(grouplike, true)
-
   root.select(appElement)
 
   // Load up initial state
@@ -79,5 +65,3 @@ const setupClient = async ({backend, writable, interfacer, appConfig}) => {
 
   return {appElement, cleanTerminal, dirtyTerminal, flushable, root}
 }
-
-module.exports = setupClient
diff --git a/combine-album.js b/combine-album.js
new file mode 100644
index 0000000..3b57b6c
--- /dev/null
+++ b/combine-album.js
@@ -0,0 +1,223 @@
+'use strict'
+
+import {readdir, readFile, stat, writeFile} from 'node:fs/promises'
+import {spawn} from 'node:child_process'
+import path from 'node:path'
+
+import shellescape from 'shell-escape'
+
+import {musicExtensions} from './crawlers.js'
+import {getTimeStringsFromSec, parseOptions, promisifyProcess} from './general-util.js'
+
+async function timestamps(files) {
+  const tsData = []
+
+  let timestamp = 0
+  for (const file of files) {
+    const args = [
+      '-print_format', 'json',
+      '-show_entries', 'stream=codec_name:format',
+      '-select_streams', 'a:0',
+      '-v', 'quiet',
+      file
+    ]
+
+    const ffprobe = spawn('ffprobe', args)
+
+    let data = ''
+    ffprobe.stdout.on('data', chunk => {
+      data += chunk
+    })
+
+    await promisifyProcess(ffprobe, false)
+
+    let result
+    try {
+      result = JSON.parse(data)
+    } catch (error) {
+      throw new Error(`Failed to parse ffprobe output - cmd: ffprobe ${args.join(' ')}`)
+    }
+
+    const duration = parseFloat(result.format.duration)
+
+    tsData.push({
+      comment: path.basename(file, path.extname(file)),
+      timestamp,
+      timestampEnd: (timestamp += duration)
+    })
+  }
+
+  // Serialize to a nicer format.
+  for (const ts of tsData) {
+    ts.timestamp = Math.trunc(ts.timestamp * 100) / 100
+    ts.timestampEnd = Math.trunc(ts.timestampEnd * 100) / 100
+  }
+
+  return tsData
+}
+
+async function main() {
+  const validFormats = ['txt', 'json']
+
+  let files = []
+
+  const opts = await parseOptions(process.argv.slice(2), {
+    'format': {
+      type: 'value',
+      validate(value) {
+        if (validFormats.includes(value)) {
+          return true
+        } else {
+          return `a valid output format (${validFormats.join(', ')})`
+        }
+      }
+    },
+
+    'no-concat-list': {type: 'flag'},
+    'concat-list': {type: 'value'},
+
+    'out': {type: 'value'},
+    'o': {alias: 'out'},
+
+    [parseOptions.handleDashless]: opt => files.push(opt)
+  })
+
+  if (files.length === 0) {
+    console.error(`Please provide either a directory (album) or a list of tracks to generate timestamps from.`)
+    return 1
+  }
+
+  if (!opts.format) {
+    opts.format = 'txt'
+  }
+
+  let defaultOut = false
+  let outFromDirectory
+  if (!opts.out) {
+    opts.out = `timestamps.${opts.format}`
+    defaultOut = true
+  }
+
+  const stats = []
+
+  {
+    let errored = false
+    for (const file of files) {
+      try {
+        stats.push(await stat(file))
+      } catch (error) {
+        console.error(`Failed to stat ${file}`)
+        errored = true
+      }
+    }
+    if (errored) {
+      console.error(`One or more paths provided failed to stat.`)
+      console.error(`There are probably permission issues preventing access!`)
+      return 1
+    }
+  }
+
+  if (stats.some(s => !s.isFile() && !s.isDirectory())) {
+    console.error(`A path was provided which isn't a file or a directory.`);
+    console.error(`This utility doesn't know what to do with that!`);
+    return 1
+  }
+
+  if (stats.length > 1 && !stats.every(s => s.isFile())) {
+    if (stats.some(s => s.isFile())) {
+      console.error(`Please don't provide a mix of files and directories.`)
+    } else {
+      console.error(`Please don't provide more than one directory.`)
+    }
+    console.error(`This utility is only capable of generating a timestamps file from either one directory (an album) or a list of (audio) files.`)
+    return 1
+  }
+
+  if (files.length === 1 && stats[0].isDirectory()) {
+    const dir = files[0]
+    try {
+      files = await readdir(dir)
+      files = files.filter(f => musicExtensions.includes(path.extname(f).slice(1)))
+    } catch (error) {
+      console.error(`Failed to read ${dir} as directory.`)
+      console.error(error)
+      console.error(`Please provide a readable directory or multiple audio files.`)
+      return 1
+    }
+    files = files.map(file => path.join(dir, file))
+    if (defaultOut) {
+      opts.out = path.join(path.dirname(dir), path.basename(dir) + '.timestamps.' + opts.format)
+      outFromDirectory = dir.replace(new RegExp(path.sep + '$'), '')
+    }
+  } else if (process.argv.length > 3) {
+    files = process.argv.slice(2)
+  } else {
+    console.error(`Please provide an album directory or multiple audio files.`)
+    return 1
+  }
+
+  let tsData
+  try {
+    tsData = await timestamps(files)
+  } catch (error) {
+    console.error(`Ran into a code error while processing timestamps:`)
+    console.error(error)
+    return 1
+  }
+
+  const duration = tsData[tsData.length - 1].timestampEnd
+
+  let tsText
+  switch (opts.format) {
+    case 'json':
+      tsText = JSON.stringify(tsData) + '\n'
+      break
+    case 'txt':
+      tsText = tsData.map(t => `${getTimeStringsFromSec(t.timestamp, duration, true).timeDone} ${t.comment}`).join('\n') + '\n'
+      break
+  }
+
+  if (opts.out === '-') {
+    process.stdout.write(tsText)
+  } else {
+    try {
+      writeFile(opts.out, tsText)
+    } catch (error) {
+      console.error(`Failed to write to output file ${opts.out}`)
+      console.error(`Confirm path is writeable or pass "--out -" to print to stdout`)
+      return 1
+    }
+  }
+
+  console.log(`Wrote timestamps to ${opts.out}`)
+
+  if (!opts['no-concat-list']) {
+    const concatOutput = (
+      (defaultOut
+        ? (outFromDirectory || 'album')
+        : `/path/to/album`)
+      + path.extname(files[0]))
+
+    const concatListPath = opts['concat-list'] || `/tmp/combine-album-concat.txt`
+    try {
+      await writeFile(concatListPath, files.map(file => `file ${shellescape([path.resolve(file)])}`).join('\n') + '\n')
+      console.log(`Generated ffmpeg concat list at ${concatListPath}`)
+      console.log(`# To concat:`)
+      console.log(`ffmpeg -f concat -safe 0 -i ${shellescape([concatListPath])} -c copy ${shellescape([concatOutput])}`)
+    } catch (error) {
+      console.warn(`Failed to generate ffmpeg concat list`)
+      console.warn(error)
+    } finally {
+      console.log(`(Pass --no-concat-list to skip this step)`)
+    }
+  }
+
+  return 0
+}
+
+main().then(
+  code => process.exit(code),
+  err => {
+    console.error(err)
+    process.exit(1)
+  })
diff --git a/crawlers.js b/crawlers.js
index 3f6e391..8197095 100644
--- a/crawlers.js
+++ b/crawlers.js
@@ -1,15 +1,25 @@
-const fs = require('fs')
-const path = require('path')
-const expandHomeDir = require('expand-home-dir')
-const fetch = require('node-fetch')
-const url = require('url')
-const { downloadPlaylistFromOptionValue, promisifyProcess } = require('./general-util')
-const { spawn } = require('child_process')
-const { orderBy } = require('natural-orderby')
-
-const { promisify } = require('util')
-const readDir = promisify(fs.readdir)
-const stat = promisify(fs.stat)
+import {spawn} from 'node:child_process'
+import {readdir, stat} from 'node:fs/promises'
+import url from 'node:url'
+import path from 'node:path'
+
+import {orderBy} from 'natural-orderby'
+import expandHomeDir from 'expand-home-dir'
+// import fetch from 'node-fetch'
+
+import {downloadPlaylistFromOptionValue, promisifyProcess} from './general-util.js'
+
+export const musicExtensions = [
+  'ogg', 'oga',
+  'wav', 'mp3', 'm4a', 'aac', 'flac', 'opus',
+  'mp4', 'mov', 'mkv',
+  'mod'
+]
+
+export const skipNames = [
+  '.DS_Store',
+  '.git',
+]
 
 // Each value is a function with these additional properties:
 // * crawlerName: The name of the crawler, such as "crawl-http". Used by
@@ -21,7 +31,7 @@ const stat = promisify(fs.stat)
 const allCrawlers = {}
 
 /* TODO: Removed cheerio, so crawl-http no longer works.
-function crawlHTTP(absURL, opts = {}, internals = {}) {
+export function crawlHTTP(absURL, opts = {}, internals = {}) {
   // Recursively crawls a given URL, following every link to a deeper path and
   // recording all links in a tree (in the same format playlists use). Makes
   // multiple attempts to download failed paths.
@@ -229,12 +239,7 @@ function getHTMLLinks(text) {
 }
 */
 
-function crawlLocal(dirPath, extensions = [
-  'ogg', 'oga',
-  'wav', 'mp3', 'm4a', 'aac', 'flac', 'opus',
-  'mp4', 'mov', 'mkv',
-  'mod'
-], isTop = true) {
+function crawlLocal(dirPath, extensions = musicExtensions, isTop = true) {
   // If the passed path is a file:// URL, try to decode it:
   try {
     const url = new URL(dirPath)
@@ -247,10 +252,16 @@ function crawlLocal(dirPath, extensions = [
     dirPath = expandHomeDir(dirPath)
   }
 
-  return readDir(dirPath).then(items => {
+  return readdir(dirPath).then(items => {
     items = orderBy(items)
 
     return Promise.all(items.map(item => {
+      // There are a few files which are just never what we're looking for.
+      // We skip including or searching under these altogether.
+      if (skipNames.includes(item)) {
+        return null
+      }
+
       const itemPath = path.join(dirPath, item)
       const itemURL = url.pathToFileURL(itemPath).href
 
@@ -274,7 +285,7 @@ function crawlLocal(dirPath, extensions = [
             return {name: item, url: itemURL}
           }
         }
-      }, statErr => null)
+      }, _statErr => null)
     }))
   }, err => {
     if (err.code === 'ENOENT') {
@@ -321,7 +332,7 @@ crawlLocal.isAppropriateForArg = function(arg) {
 
 allCrawlers.crawlLocal = crawlLocal
 
-async function crawlYouTube(url) {
+export async function crawlYouTube(url) {
   const ytdl = spawn('youtube-dl', [
     '-j', // Output as JSON
     '--flat-playlist',
@@ -381,7 +392,7 @@ crawlYouTube.isAppropriateForArg = function(arg) {
 
 allCrawlers.crawlYouTube = crawlYouTube
 
-async function openFile(input) {
+export async function openFile(input) {
   return JSON.parse(await downloadPlaylistFromOptionValue(input))
 }
 
@@ -394,14 +405,10 @@ openFile.isAppropriateForArg = function(arg) {
 
 allCrawlers.openFile = openFile
 
-// Actual module.exports stuff:
-
-Object.assign(module.exports, allCrawlers)
-
-module.exports.getCrawlerByName = function(name) {
+export function getCrawlerByName(name) {
   return Object.values(allCrawlers).find(fn => fn.crawlerName === name)
 }
 
-module.exports.getAllCrawlersForArg = function(arg) {
+export function getAllCrawlersForArg(arg) {
   return Object.values(allCrawlers).filter(fn => fn.isAppropriateForArg(arg))
 }
diff --git a/downloaders.js b/downloaders.js
index 941c805..9e7c786 100644
--- a/downloaders.js
+++ b/downloaders.js
@@ -1,25 +1,21 @@
-const { promisifyProcess } = require('./general-util')
-const { promisify } = require('util')
-const { spawn } = require('child_process')
-const { URL } = require('url')
-const mkdirp = promisify(require('mkdirp'))
-const fs = require('fs')
-const fetch = require('node-fetch')
-const tempy = require('tempy')
-const os = require('os')
-const path = require('path')
-const sanitize = require('sanitize-filename')
-
-const writeFile = promisify(fs.writeFile)
-const rename = promisify(fs.rename)
-const stat = promisify(fs.stat)
-const readdir = promisify(fs.readdir)
-const symlink = promisify(fs.symlink)
+import {spawn} from 'node:child_process'
+import {createReadStream, createWriteStream} from 'node:fs'
+import {readdir, rename, stat, symlink, writeFile} from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import url from 'node:url'
+
+import {mkdirp} from 'mkdirp'
+import fetch from 'node-fetch'
+import sanitize from 'sanitize-filename'
+import tempy from 'tempy'
+
+import {promisifyProcess} from './general-util.js'
 
 const copyFile = (source, target) => {
   // Stolen from https://stackoverflow.com/a/30405105/4633828
-  const rd = fs.createReadStream(source)
-  const wr = fs.createWriteStream(target)
+  const rd = createReadStream(source)
+  const wr = createWriteStream(target)
   return new Promise((resolve, reject) => {
     rd.on('error', reject)
     wr.on('error', reject)
@@ -32,7 +28,7 @@ const copyFile = (source, target) => {
   })
 }
 
-// const disableBackResolving = arg => arg.split('/').map(str => str.replace(/^\../, '_..')).join('/')
+export const rootCacheDir = path.join(os.homedir(), '.mtui', 'downloads')
 
 const cachify = (identifier, keyFunction, baseFunction) => {
   return async arg => {
@@ -43,7 +39,7 @@ const cachify = (identifier, keyFunction, baseFunction) => {
 
     // Determine where the final file will end up. This is just a directory -
     // the file's own name is determined by the downloader.
-    const cacheDir = downloaders.rootCacheDir + '/' + identifier
+    const cacheDir = rootCacheDir + '/' + identifier
     const finalDirectory = cacheDir + '/' + sanitize(keyFunction(arg))
 
     // Check if that directory only exists. If it does, return the file in it,
@@ -102,15 +98,16 @@ const removeFileProtocol = arg => {
   }
 }
 
-const downloaders = {
-  extension: 'mp3', // Generally target file extension, used by youtube-dl
+// Generally target file extension, used by youtube-dl
+export const extension = 'mp3'
 
-  rootCacheDir: os.homedir() + '/.mtui/downloads',
+const downloaders = {}
 
-  http: cachify('http',
+downloaders.http =
+  cachify('http',
     arg => {
-      const url = new URL(arg)
-      return url.hostname + url.pathname
+      const {hostname, pathname} = new url.URL(arg)
+      return hostname + pathname
     },
     arg => {
       const out = (
@@ -121,9 +118,10 @@ const downloaders = {
         .then(response => response.buffer())
         .then(buffer => writeFile(out, buffer))
         .then(() => out)
-    }),
+    })
 
-  youtubedl: cachify('youtubedl',
+downloaders.youtubedl =
+  cachify('youtubedl',
     arg => (arg.match(/watch\?v=(.*)/) || ['', arg])[1],
     arg => {
       const outDir = tempy.directory()
@@ -133,7 +131,7 @@ const downloaders = {
         '--quiet',
         '--no-warnings',
         '--extract-audio',
-        '--audio-format', downloaders.extension,
+        '--audio-format', extension,
         '--output', outFile,
         arg
       ]
@@ -141,9 +139,10 @@ const downloaders = {
       return promisifyProcess(spawn('youtube-dl', opts))
         .then(() => readdir(outDir))
         .then(files => outDir + '/' + files[0])
-    }),
+    })
 
-  local: cachify('local',
+downloaders.local =
+  cachify('local',
     arg => arg,
     arg => {
       // Usually we'd just return the given argument in a local
@@ -171,9 +170,10 @@ const downloaders = {
 
       return copyFile(arg, out)
         .then(() => out)
-    }),
+    })
 
-  locallink: cachify('locallink',
+downloaders.locallink =
+  cachify('locallink',
     arg => arg,
     arg => {
       // Like the local downloader, but creates a symbolic link to the argument.
@@ -184,22 +184,22 @@ const downloaders = {
 
       return symlink(path.resolve(arg), out)
         .then(() => out)
-    }),
+    })
 
-  echo: arg => arg,
+downloaders.echo =
+  arg => arg
 
-  getDownloaderFor: arg => {
-    if (arg.startsWith('http://') || arg.startsWith('https://')) {
-      if (arg.includes('youtube.com')) {
-        return downloaders.youtubedl
-      } else {
-        return downloaders.http
-      }
+export default downloaders
+
+export function getDownloaderFor(arg) {
+  if (arg.startsWith('http://') || arg.startsWith('https://')) {
+    if (arg.includes('youtube.com')) {
+      return downloaders.youtubedl
     } else {
-      // return downloaders.local
-      return downloaders.locallink
+      return downloaders.http
     }
+  } else {
+    // return downloaders.local
+    return downloaders.locallink
   }
 }
-
-module.exports = downloaders
diff --git a/general-util.js b/general-util.js
index 0f5bdd5..d369848 100644
--- a/general-util.js
+++ b/general-util.js
@@ -1,13 +1,11 @@
-const { spawn } = require('child_process')
-const { promisify } = require('util')
-const fetch = require('node-fetch')
-const fs = require('fs')
-const npmCommandExists = require('command-exists')
-const url = require('url')
+import {spawn} from 'node:child_process'
+import {readFile} from 'node:fs/promises'
+import {fileURLToPath, URL} from 'node:url'
 
-const readFile = promisify(fs.readFile)
+import npmCommandExists from 'command-exists'
+import fetch from 'node-fetch'
 
-module.exports.promisifyProcess = function(proc, showLogging = true) {
+export function promisifyProcess(proc, showLogging = true) {
   // Takes a process (from the child_process module) and returns a promise
   // that resolves when the process exits (or rejects, if the exit code is
   // non-zero).
@@ -28,7 +26,7 @@ module.exports.promisifyProcess = function(proc, showLogging = true) {
   })
 }
 
-module.exports.commandExists = async function(command) {
+export async function commandExists(command) {
   // When the command-exists module sees that a given command doesn't exist, it
   // throws an error instead of returning false, which is not what we want.
 
@@ -39,12 +37,12 @@ module.exports.commandExists = async function(command) {
   }
 }
 
-module.exports.killProcess = async function(proc) {
+export async function killProcess(proc) {
   // Windows is stupid and doesn't like it when we try to kill processes.
   // So instead we use taskkill! https://stackoverflow.com/a/28163919/4633828
 
-  if (await module.exports.commandExists('taskkill')) {
-    await module.exports.promisifyProcess(
+  if (await commandExists('taskkill')) {
+    await promisifyProcess(
       spawn('taskkill', ['/pid', proc.pid, '/f', '/t']),
       false
     )
@@ -53,18 +51,18 @@ module.exports.killProcess = async function(proc) {
   }
 }
 
-function downloadPlaylistFromURL(url) {
+export function downloadPlaylistFromURL(url) {
   return fetch(url).then(res => res.text())
 }
 
-function downloadPlaylistFromLocalPath(path) {
+export function downloadPlaylistFromLocalPath(path) {
   return readFile(path).then(buf => buf.toString())
 }
 
-module.exports.downloadPlaylistFromOptionValue = function(arg) {
+export function downloadPlaylistFromOptionValue(arg) {
   let argURL
   try {
-    argURL = new url.URL(arg)
+    argURL = new URL(arg)
   } catch (err) {
     // Definitely not a URL.
   }
@@ -73,14 +71,14 @@ module.exports.downloadPlaylistFromOptionValue = function(arg) {
     if (argURL.protocol === 'http:' || argURL.protocol === 'https:') {
       return downloadPlaylistFromURL(arg)
     } else if (argURL.protocol === 'file:') {
-      return downloadPlaylistFromLocalPath(url.fileURLToPath(argURL))
+      return downloadPlaylistFromLocalPath(fileURLToPath(argURL))
     }
   } else {
     return downloadPlaylistFromLocalPath(arg)
   }
 }
 
-module.exports.shuffleArray = function(array) {
+export function shuffleArray(array) {
   // Shuffles the items in an array. Returns a new array (does not modify the
   // passed array). Super-interesting post on how this algorithm works:
   // https://bost.ocks.org/mike/shuffle/
@@ -103,7 +101,7 @@ module.exports.shuffleArray = function(array) {
   return workingArray
 }
 
-module.exports.throttlePromise = function(maximumAtOneTime = 10) {
+export function throttlePromise(maximumAtOneTime = 10) {
   // Returns a function that takes a callback to create a promise and either
   // runs it now, if there is an available slot, or enqueues it to be run
   // later, if there is not.
@@ -139,61 +137,99 @@ module.exports.throttlePromise = function(maximumAtOneTime = 10) {
   return enqueue
 }
 
-module.exports.getTimeStringsFromSec = function(curSecTotal, lenSecTotal) {
-  const percentVal = (100 / lenSecTotal) * curSecTotal
-  const percentDone = (
-    (Math.trunc(percentVal * 100) / 100).toFixed(2) + '%'
-  )
+export function getSecFromTimestamp(timestamp) {
+  const parts = timestamp.split(':').map(n => parseInt(n))
+  switch (parts.length) {
+    case 3: return parts[0] * 3600 + parts[1] * 60 + parts[2]
+    case 2: return parts[0] * 60 + parts[1]
+    case 1: return parts[0]
+    default: return 0
+  }
+}
+
+export function getTimeStringsFromSec(curSecTotal, lenSecTotal = null, fraction = false) {
+  const pad = val => val.toString().padStart(2, '0')
+  const padFrac = val => Math.floor(val * 1000).toString().padEnd(3, '0')
+
+  // We don't want to display hour counters if the total length is less
+  // than an hour.
+  const displayAsHours = Math.max(curSecTotal, lenSecTotal ?? 0) >= 3600
 
-  const leftSecTotal = lenSecTotal - curSecTotal
-  let leftHour = Math.floor(leftSecTotal / 3600)
-  let leftMin = Math.floor((leftSecTotal - leftHour * 3600) / 60)
-  let leftSec = Math.floor(leftSecTotal - leftHour * 3600 - leftMin * 60)
+  const strings = {curSecTotal, lenSecTotal}
 
-  // Yeah, yeah, duplicate math.
   let curHour = Math.floor(curSecTotal / 3600)
   let curMin = Math.floor((curSecTotal - curHour * 3600) / 60)
   let curSec = Math.floor(curSecTotal - curHour * 3600 - curMin * 60)
+  let curFrac = curSecTotal % 1
 
-  // Wee!
-  let lenHour = Math.floor(lenSecTotal / 3600)
-  let lenMin = Math.floor((lenSecTotal - lenHour * 3600) / 60)
-  let lenSec = Math.floor(lenSecTotal - lenHour * 3600 - lenMin * 60)
-
-  const pad = val => val.toString().padStart(2, '0')
   curMin = pad(curMin)
   curSec = pad(curSec)
-  lenMin = pad(lenMin)
-  lenSec = pad(lenSec)
-  leftMin = pad(leftMin)
-  leftSec = pad(leftSec)
+  curFrac = padFrac(curFrac)
 
-  // We don't want to display hour counters if the total length is less
-  // than an hour.
-  let timeDone, timeLeft, duration
-  if (parseInt(lenHour) > 0) {
-    timeDone = `${curHour}:${curMin}:${curSec}`
-    timeLeft = `${leftHour}:${leftMin}:${leftSec}`
-    duration = `${lenHour}:${lenMin}:${lenSec}`
+  if (displayAsHours) {
+    strings.timeDone = `${curHour}:${curMin}:${curSec}`
   } else {
-    timeDone = `${curMin}:${curSec}`
-    timeLeft = `${leftMin}:${leftSec}`
-    duration = `${lenMin}:${lenSec}`
+    strings.timeDone = `${curMin}:${curSec}`
+  }
+
+  if (fraction) {
+    strings.timeDone += '.' + curFrac
   }
 
-  return {percentDone, timeDone, timeLeft, duration, curSecTotal, lenSecTotal}
+  if (typeof lenSecTotal === 'number') {
+    const percentVal = (100 / lenSecTotal) * curSecTotal
+    strings.percentDone = (Math.trunc(percentVal * 100) / 100).toFixed(2) + '%'
+
+    // Yeah, yeah, duplicate math.
+    const leftSecTotal = lenSecTotal - curSecTotal
+    let leftHour = Math.floor(leftSecTotal / 3600)
+    let leftMin = Math.floor((leftSecTotal - leftHour * 3600) / 60)
+    let leftSec = Math.floor(leftSecTotal - leftHour * 3600 - leftMin * 60)
+    let leftFrac = leftSecTotal % 1
+
+    // Wee!
+    let lenHour = Math.floor(lenSecTotal / 3600)
+    let lenMin = Math.floor((lenSecTotal - lenHour * 3600) / 60)
+    let lenSec = Math.floor(lenSecTotal - lenHour * 3600 - lenMin * 60)
+    let lenFrac = lenSecTotal % 1
+
+    lenMin = pad(lenMin)
+    lenSec = pad(lenSec)
+    lenFrac = padFrac(lenFrac)
+
+    leftMin = pad(leftMin)
+    leftSec = pad(leftSec)
+    leftFrac = padFrac(leftFrac)
+
+    if (typeof lenSecTotal === 'number') {
+      if (displayAsHours) {
+        strings.timeLeft = `${leftHour}:${leftMin}:${leftSec}`
+        strings.duration = `${lenHour}:${lenMin}:${lenSec}`
+      } else {
+        strings.timeLeft = `${leftMin}:${leftSec}`
+        strings.duration = `${lenMin}:${lenSec}`
+      }
+
+      if (fraction) {
+        strings.timeLeft += '.' + leftFrac
+        strings.duration += '.' + lenFrac
+      }
+    }
+  }
+
+  return strings
 }
 
-module.exports.getTimeStrings = function({curHour, curMin, curSec, lenHour, lenMin, lenSec}) {
+export function getTimeStrings({curHour, curMin, curSec, lenHour, lenMin, lenSec}) {
   // Multiplication casts to numbers; addition prioritizes strings.
   // Thanks, JavaScript!
   const curSecTotal = (3600 * curHour) + (60 * curMin) + (1 * curSec)
   const lenSecTotal = (3600 * lenHour) + (60 * lenMin) + (1 * lenSec)
 
-  return module.exports.getTimeStringsFromSec(curSecTotal, lenSecTotal)
+  return getTimeStringsFromSec(curSecTotal, lenSecTotal)
 }
 
-const parseOptions = async function(options, optionDescriptorMap) {
+export async function parseOptions(options, optionDescriptorMap) {
   // This function is sorely lacking in comments, but the basic usage is
   // as such:
   //
@@ -309,9 +345,7 @@ const parseOptions = async function(options, optionDescriptorMap) {
 
 parseOptions.handleDashless = Symbol()
 
-module.exports.parseOptions = parseOptions
-
-module.exports.silenceEvents = async function(emitter, eventsToSilence, callback) {
+export async function silenceEvents(emitter, eventsToSilence, callback) {
   const oldEmit = emitter.emit
 
   emitter.emit = function(event, ...data) {
@@ -324,3 +358,14 @@ module.exports.silenceEvents = async function(emitter, eventsToSilence, callback
 
   emitter.emit = oldEmit
 }
+
+// Kindly stolen from ESDiscuss:
+// https://esdiscuss.org/topic/proposal-add-an-option-to-omit-prototype-of-objects-created-by-json-parse#content-1
+export function parseWithoutPrototype(string) {
+  return JSON.parse(string, function(k, v) {
+    if (v && typeof v === 'object' && !Array.isArray(v)) {
+      return Object.assign(Object.create(null), v)
+    }
+    return v
+  })
+}
diff --git a/guess.js b/guess.js
index db9f8e8..3c72f64 100644
--- a/guess.js
+++ b/guess.js
@@ -1,21 +1,13 @@
 'use strict'
 
-const Backend = require('./backend')
-const os = require('os')
-const processSmartPlaylist = require('./smart-playlist')
-
-const {
-  flattenGrouplike,
-  parentSymbol,
-  searchForItem
-} = require('./playlist-utils')
-
-const {
-  util: {
-    ansi,
-    telchars: telc
-  }
-} = require('tui-lib')
+import os from 'node:os'
+
+import * as ansi from 'tui-lib/util/ansi'
+import telc from 'tui-lib/util/telchars'
+
+import {flattenGrouplike, parentSymbol, searchForItem} from './playlist-utils.js'
+import processSmartPlaylist from './smart-playlist.js'
+import Backend from './backend.js'
 
 function untilEvent(object, event) {
   return new Promise(resolve => {
diff --git a/index.js b/index.js
index 3ecd59b..6aad592 100755
--- a/index.js
+++ b/index.js
@@ -2,42 +2,26 @@
 
 // omg I am tired of code
 
-const { getAllCrawlersForArg } = require('./crawlers')
-const { getPlayer } = require('./players')
-const { parseOptions } = require('./general-util')
-const AppElement = require('./ui')
-const Backend = require('./backend')
-const TelnetServer = require('./telnet')
-const processSmartPlaylist = require('./smart-playlist')
-const setupClient = require('./client')
-
-const {
+import {getPlayer} from './players.js'
+import {parseOptions} from './general-util.js'
+import {getItemPathString} from './playlist-utils.js'
+import Backend from './backend.js'
+import setupClient from './client.js'
+import TelnetServer from './telnet.js'
+
+import {
   makeSocketServer,
   makeSocketClient,
   attachBackendToSocketClient,
-  attachSocketServerToBackend
-} = require('./socket')
-
-const {
-  getItemPathString,
-  updatePlaylistFormat
-} = require('./playlist-utils')
-
-const {
-  ui: {
-    Root
-  },
-  util: {
-    ansi,
-    CommandLineInterfacer,
-    Flushable
-  }
-} = require('tui-lib')
+  attachSocketServerToBackend,
+} from './socket.js'
+
+import {CommandLineInterface} from 'tui-lib/util/interfaces'
+import * as ansi from 'tui-lib/util/ansi'
 
-const { promisify } = require('util')
-const fs = require('fs')
-const readFile = promisify(fs.readFile)
-const writeFile = promisify(fs.writeFile)
+import {readFile, writeFile} from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
 
 // Hack to get around errors when piping many things to stdout/err
 // (from general-util promisifyProcess)
@@ -72,15 +56,19 @@ 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'},
+    'skip-config-file': {type: 'flag'},
+    'config-file': {type: 'value'},
+
     [parseOptions.handleDashless](option) {
       playlistSources.push(option)
-    }
+    },
   })
 
   if (options['player-options'] && !options['player']) {
@@ -88,33 +76,92 @@ async function main() {
     process.exit(1)
   }
 
-  const backend = new Backend({
-    playerName: options['player'],
-    playerOptions: options['player-options']
-  })
+  let jsonConfig = {}
+  let jsonError = null
+
+  const jsonPath =
+    (options['config-file']
+      ? path.resolve(options['config-file'])
+      : path.join(os.homedir(), '.mtui', 'config.json'))
+
+  try {
+    jsonConfig = JSON.parse(await readFile(jsonPath))
+  } catch (error) {
+    if (error.code !== 'ENOENT') {
+      jsonError = error
+    }
+  }
 
-  const result = await backend.setup()
-  if (result.error) {
-    console.error(result.error)
+  if (jsonError) {
+    console.error(`Error loading JSON config:`)
+    console.error(jsonError.message)
+    console.error(`Edit the file below to fix the error, or run mtui with --skip-config-file.`)
+    console.error(jsonPath)
     process.exit(1)
   }
 
-  backend.on('playing', track => {
-    if (track) {
-      writeFile(backend.rootDirectory + '/current-track.txt',
-        getItemPathString(track))
-      writeFile(backend.rootDirectory + '/current-track.json',
-        JSON.stringify(track, null, 2))
-    }
-  })
+  const backendConfig =
+    (options['socket-server']
+      ? {
+          playerName: 'ghost',
+        }
+      : {
+          playerName: options['player'],
+          playerOptions: options['player-options'],
+        })
+
+  const appConfig =
+    (options['socket-server']
+      ? {
+          showPartyControls: true,
+          canControlPlayback: false,
+          canControlQueue: false,
+          canControlQueuePlayers: false,
+          canProcessMetadata: false,
+        }
+   : options['socket-client']
+      ? {
+          showPartyControls: true,
+        }
+      : {})
+
+  const backend = new Backend(backendConfig)
+
+  const setupResult = await backend.setup()
+  if (setupResult.error) {
+    console.error(setupResult.error)
+    process.exit(1)
+  }
+
+  if (options['socket-server']) {
+    const socketServer = makeSocketServer()
+    attachSocketServerToBackend(socketServer, backend)
+    socketServer.listen(options['socket-server'])
+
+    const socketClient = makeSocketClient()
+    attachBackendToSocketClient(backend, socketClient)
+    socketClient.socket.connect(options['socket-server'])
+
+    backend.setPartyNickname('Internal Client')
+    backend.announceJoinParty()
+  }
+
+  if (!options['socket-server']) {
+    backend.on('playing', track => {
+      if (track) {
+        writeFile(backend.rootDirectory + '/current-track.txt',
+          getItemPathString(track))
+        writeFile(backend.rootDirectory + '/current-track.json',
+          JSON.stringify(track, null, 2))
+      }
+    })
+  }
 
   const { appElement, dirtyTerminal, flushable, root } = await setupClient({
     backend,
-    interfacer: new CommandLineInterfacer(),
+    screenInterface: new CommandLineInterface(),
     writable: process.stdout,
-    appConfig: {
-      showPartyControls: !!(options['socket-server'] || options['socket-client'])
-    }
+    appConfig,
   })
 
   appElement.on('quitRequested', () => {
@@ -136,6 +183,20 @@ async function main() {
     root.renderNow()
   })
 
+  if (!options['socket-server'] && playlistSources.length === 0) {
+    if (jsonConfig.defaultPlaylists) {
+      playlistSources.push(...jsonConfig.defaultPlaylists)
+    } else {
+      playlistSources.push({
+        name: 'My ~/Music Library',
+        comment: (
+          '(Add tracks and folders to ~/Music to make them show up here,' +
+          ' or pass mtui your own playlist.json file!)'),
+        source: ['crawl-local', os.homedir() + '/Music']
+      })
+    }
+  }
+
   const loadPlaylists = async () => {
     for (const source of playlistSources) {
       await appElement.loadPlaylistOrSource(source, true)
@@ -151,26 +212,13 @@ 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 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)
 
     let nickname = process.env.USER
@@ -191,6 +239,7 @@ async function main() {
     root.h = h
     root.fixAllLayout()
 
+    /* eslint-disable-next-line no-unused-vars */
     const XXstress = func => '[disabled]'
 
     const stress = func => {
diff --git a/metadata-readers.js b/metadata-readers.js
index 64f413a..d0f5f55 100644
--- a/metadata-readers.js
+++ b/metadata-readers.js
@@ -1,5 +1,6 @@
-const { promisifyProcess } = require('./general-util')
-const { spawn } = require('child_process')
+import {spawn} from 'node:child_process'
+
+import {promisifyProcess} from './general-util.js'
 
 // Some probers are sorta inconsistent; this function lets them try again if
 // they fail the first time.
@@ -21,8 +22,10 @@ const tryAgain = function(times, func) {
   }
 }
 
-const metadataReaders = {
-  ffprobe: tryAgain(6, async filePath => {
+const metadataReaders = {}
+
+metadataReaders.ffprobe =
+  tryAgain(6, async filePath => {
     const ffprobe = spawn('ffprobe', [
       '-print_format', 'json',
       '-show_entries', 'stream=codec_name:format',
@@ -37,7 +40,11 @@ const metadataReaders = {
       probeDataString += data
     })
 
-    await promisifyProcess(ffprobe, false)
+    try {
+      await promisifyProcess(ffprobe, false)
+    } catch (error) {
+      return null
+    }
 
     let data
 
@@ -56,11 +63,11 @@ const metadataReaders = {
       fileSize: parseInt(data.format.size),
       bitrate: parseInt(data.format.bit_rate)
     }
-  }),
+  })
 
-  getMetadataReaderFor: arg => {
-    return metadataReaders.ffprobe
-  }
-}
+export default metadataReaders
 
-module.exports = metadataReaders
+export function getMetadataReaderFor(_arg) {
+  // Only the one metadata reader implemented, so far!
+  return metadataReaders.ffprobe
+}
diff --git a/package-lock.json b/package-lock.json
index 3d18627..25df086 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,74 +1,756 @@
 {
   "name": "mtui",
   "version": "0.0.1",
-  "lockfileVersion": 2,
+  "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
+      "name": "mtui",
       "version": "0.0.1",
       "license": "GPL-3.0",
       "dependencies": {
         "command-exists": "^1.2.9",
         "expand-home-dir": "0.0.3",
-        "mkdirp": "^0.5.5",
+        "mkdirp": "^3.0.1",
         "natural-orderby": "^2.0.3",
         "node-fetch": "^2.6.0",
         "open": "^7.0.4",
         "sanitize-filename": "^1.6.3",
+        "shell-escape": "^0.2.0",
         "shortid": "^2.2.15",
         "tempy": "^0.2.1",
-        "tui-lib": "^0.3.1"
+        "tui-lib": "^0.4.0",
+        "tui-text-editor": "^0.3.1"
       },
       "bin": {
         "mtui": "index.js"
+      },
+      "devDependencies": {
+        "eslint": "^8.40.0"
+      }
+    },
+    "node_modules/@eslint-community/eslint-utils": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
+      "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
+      "dev": true,
+      "dependencies": {
+        "eslint-visitor-keys": "^3.3.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "peerDependencies": {
+        "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+      }
+    },
+    "node_modules/@eslint-community/regexpp": {
+      "version": "4.5.1",
+      "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.1.tgz",
+      "integrity": "sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==",
+      "dev": true,
+      "engines": {
+        "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+      }
+    },
+    "node_modules/@eslint/eslintrc": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.3.tgz",
+      "integrity": "sha512-+5gy6OQfk+xx3q0d6jGZZC3f3KzAkXc/IanVxd1is/VIIziRqqt3ongQz0FiTUXqTk0c7aDB3OaFuKnuSoJicQ==",
+      "dev": true,
+      "dependencies": {
+        "ajv": "^6.12.4",
+        "debug": "^4.3.2",
+        "espree": "^9.5.2",
+        "globals": "^13.19.0",
+        "ignore": "^5.2.0",
+        "import-fresh": "^3.2.1",
+        "js-yaml": "^4.1.0",
+        "minimatch": "^3.1.2",
+        "strip-json-comments": "^3.1.1"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/@eslint/js": {
+      "version": "8.40.0",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.40.0.tgz",
+      "integrity": "sha512-ElyB54bJIhXQYVKjDSvCkPO1iU1tSAeVQJbllWJq1XQSmmA4dgFk8CbiBGpiOPxleE48vDogxCtmMYku4HSVLA==",
+      "dev": true,
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      }
+    },
+    "node_modules/@humanwhocodes/config-array": {
+      "version": "0.11.8",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",
+      "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==",
+      "dev": true,
+      "dependencies": {
+        "@humanwhocodes/object-schema": "^1.2.1",
+        "debug": "^4.1.1",
+        "minimatch": "^3.0.5"
+      },
+      "engines": {
+        "node": ">=10.10.0"
+      }
+    },
+    "node_modules/@humanwhocodes/module-importer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+      "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+      "dev": true,
+      "engines": {
+        "node": ">=12.22"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/nzakas"
+      }
+    },
+    "node_modules/@humanwhocodes/object-schema": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz",
+      "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
+      "dev": true
+    },
+    "node_modules/@nodelib/fs.scandir": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+      "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.stat": "2.0.5",
+        "run-parallel": "^1.1.9"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.stat": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+      "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+      "dev": true,
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.walk": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+      "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.scandir": "2.1.5",
+        "fastq": "^1.6.0"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/acorn": {
+      "version": "8.8.2",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz",
+      "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==",
+      "dev": true,
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/acorn-jsx": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+      "dev": true,
+      "peerDependencies": {
+        "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/ajv": {
+      "version": "6.12.6",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+      "dev": true,
+      "dependencies": {
+        "fast-deep-equal": "^3.1.1",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.4.1",
+        "uri-js": "^4.2.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "dev": true
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true
+    },
+    "node_modules/brace-expansion": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/callsites": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
       }
     },
     "node_modules/clone": {
       "version": "1.0.4",
-      "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
-      "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=",
+      "license": "MIT",
       "engines": {
         "node": ">=0.8"
       }
     },
+    "node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true
+    },
     "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=="
+      "license": "MIT"
+    },
+    "node_modules/concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+      "dev": true
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.3",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+      "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+      "dev": true,
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
     },
     "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=",
+      "license": "MIT",
       "engines": {
         "node": ">=4"
       }
     },
+    "node_modules/debug": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+      "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+      "dev": true,
+      "dependencies": {
+        "ms": "2.1.2"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/deep-is": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+      "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+      "dev": true
+    },
     "node_modules/defaults": {
       "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz",
-      "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=",
+      "license": "MIT",
       "dependencies": {
         "clone": "^1.0.2"
       }
     },
+    "node_modules/doctrine": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+      "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+      "dev": true,
+      "dependencies": {
+        "esutils": "^2.0.2"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/escape-string-regexp": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/eslint": {
+      "version": "8.40.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.40.0.tgz",
+      "integrity": "sha512-bvR+TsP9EHL3TqNtj9sCNJVAFK3fBN8Q7g5waghxyRsPLIMwL73XSKnZFK0hk/O2ANC+iAoq6PWMQ+IfBAJIiQ==",
+      "dev": true,
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.2.0",
+        "@eslint-community/regexpp": "^4.4.0",
+        "@eslint/eslintrc": "^2.0.3",
+        "@eslint/js": "8.40.0",
+        "@humanwhocodes/config-array": "^0.11.8",
+        "@humanwhocodes/module-importer": "^1.0.1",
+        "@nodelib/fs.walk": "^1.2.8",
+        "ajv": "^6.10.0",
+        "chalk": "^4.0.0",
+        "cross-spawn": "^7.0.2",
+        "debug": "^4.3.2",
+        "doctrine": "^3.0.0",
+        "escape-string-regexp": "^4.0.0",
+        "eslint-scope": "^7.2.0",
+        "eslint-visitor-keys": "^3.4.1",
+        "espree": "^9.5.2",
+        "esquery": "^1.4.2",
+        "esutils": "^2.0.2",
+        "fast-deep-equal": "^3.1.3",
+        "file-entry-cache": "^6.0.1",
+        "find-up": "^5.0.0",
+        "glob-parent": "^6.0.2",
+        "globals": "^13.19.0",
+        "grapheme-splitter": "^1.0.4",
+        "ignore": "^5.2.0",
+        "import-fresh": "^3.0.0",
+        "imurmurhash": "^0.1.4",
+        "is-glob": "^4.0.0",
+        "is-path-inside": "^3.0.3",
+        "js-sdsl": "^4.1.4",
+        "js-yaml": "^4.1.0",
+        "json-stable-stringify-without-jsonify": "^1.0.1",
+        "levn": "^0.4.1",
+        "lodash.merge": "^4.6.2",
+        "minimatch": "^3.1.2",
+        "natural-compare": "^1.4.0",
+        "optionator": "^0.9.1",
+        "strip-ansi": "^6.0.1",
+        "strip-json-comments": "^3.1.0",
+        "text-table": "^0.2.0"
+      },
+      "bin": {
+        "eslint": "bin/eslint.js"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/eslint-scope": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.0.tgz",
+      "integrity": "sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==",
+      "dev": true,
+      "dependencies": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/eslint-visitor-keys": {
+      "version": "3.4.1",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz",
+      "integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==",
+      "dev": true,
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/espree": {
+      "version": "9.5.2",
+      "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.2.tgz",
+      "integrity": "sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==",
+      "dev": true,
+      "dependencies": {
+        "acorn": "^8.8.0",
+        "acorn-jsx": "^5.3.2",
+        "eslint-visitor-keys": "^3.4.1"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/esquery": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
+      "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==",
+      "dev": true,
+      "dependencies": {
+        "estraverse": "^5.1.0"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/esrecurse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+      "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+      "dev": true,
+      "dependencies": {
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "dev": true,
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "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="
+      "license": "BSD"
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "dev": true
+    },
+    "node_modules/fast-json-stable-stringify": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+      "dev": true
+    },
+    "node_modules/fast-levenshtein": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+      "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+      "dev": true
+    },
+    "node_modules/fastq": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
+      "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==",
+      "dev": true,
+      "dependencies": {
+        "reusify": "^1.0.4"
+      }
+    },
+    "node_modules/file-entry-cache": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+      "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+      "dev": true,
+      "dependencies": {
+        "flat-cache": "^3.0.4"
+      },
+      "engines": {
+        "node": "^10.12.0 || >=12.0.0"
+      }
+    },
+    "node_modules/find-up": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+      "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+      "dev": true,
+      "dependencies": {
+        "locate-path": "^6.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/flat-cache": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
+      "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
+      "dev": true,
+      "dependencies": {
+        "flatted": "^3.1.0",
+        "rimraf": "^3.0.2"
+      },
+      "engines": {
+        "node": "^10.12.0 || >=12.0.0"
+      }
+    },
+    "node_modules/flatted": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
+      "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
+      "dev": true
+    },
+    "node_modules/fs.realpath": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+      "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+      "dev": true
+    },
+    "node_modules/glob": {
+      "version": "7.2.3",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+      "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+      "dev": true,
+      "dependencies": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.1.1",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      },
+      "engines": {
+        "node": "*"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/glob-parent": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/globals": {
+      "version": "13.20.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz",
+      "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==",
+      "dev": true,
+      "dependencies": {
+        "type-fest": "^0.20.2"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/grapheme-splitter": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
+      "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==",
+      "dev": true
+    },
+    "node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ignore": {
+      "version": "5.2.4",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
+      "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==",
+      "dev": true,
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/import-fresh": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+      "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+      "dev": true,
+      "dependencies": {
+        "parent-module": "^1.0.0",
+        "resolve-from": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/imurmurhash": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+      "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.8.19"
+      }
+    },
+    "node_modules/inflight": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+      "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+      "dev": true,
+      "dependencies": {
+        "once": "^1.3.0",
+        "wrappy": "1"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "dev": true
     },
     "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==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-path-inside": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+      "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+      "dev": true,
       "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==",
+      "license": "MIT",
       "dependencies": {
         "is-docker": "^2.0.0"
       },
@@ -76,277 +758,575 @@
         "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/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "dev": true
     },
-    "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==",
+    "node_modules/js-sdsl": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz",
+      "integrity": "sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==",
+      "dev": true,
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/js-sdsl"
+      }
+    },
+    "node_modules/js-yaml": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+      "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+      "dev": true,
       "dependencies": {
-        "minimist": "^1.2.5"
+        "argparse": "^2.0.1"
       },
       "bin": {
-        "mkdirp": "bin/cmd.js"
+        "js-yaml": "bin/js-yaml.js"
+      }
+    },
+    "node_modules/json-schema-traverse": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+      "dev": true
+    },
+    "node_modules/json-stable-stringify-without-jsonify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+      "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+      "dev": true
+    },
+    "node_modules/levn": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+      "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+      "dev": true,
+      "dependencies": {
+        "prelude-ls": "^1.2.1",
+        "type-check": "~0.4.0"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
       }
     },
+    "node_modules/locate-path": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+      "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+      "dev": true,
+      "dependencies": {
+        "p-locate": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/lodash.merge": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+      "dev": true
+    },
+    "node_modules/minimatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/mkdirp": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
+      "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
+      "bin": {
+        "mkdirp": "dist/cjs/src/bin.js"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+      "dev": true
+    },
     "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=="
+      "license": "MIT"
+    },
+    "node_modules/natural-compare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+      "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+      "dev": true
     },
     "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==",
+      "license": "MIT",
       "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==",
+      "license": "MIT",
       "engines": {
         "node": "4.x || >=6.0.0"
       }
     },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "dev": true,
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
     "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==",
+      "license": "MIT",
       "dependencies": {
         "is-docker": "^2.0.0",
         "is-wsl": "^2.1.1"
       },
       "engines": {
         "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "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==",
+    "node_modules/optionator": {
+      "version": "0.9.1",
+      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
+      "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==",
+      "dev": true,
       "dependencies": {
-        "truncate-utf8-bytes": "^1.0.0"
+        "deep-is": "^0.1.3",
+        "fast-levenshtein": "^2.0.6",
+        "levn": "^0.4.1",
+        "prelude-ls": "^1.2.1",
+        "type-check": "^0.4.0",
+        "word-wrap": "^1.2.3"
+      },
+      "engines": {
+        "node": ">= 0.8.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==",
+    "node_modules/p-limit": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+      "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+      "dev": true,
       "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=",
+        "yocto-queue": "^0.1.0"
+      },
       "engines": {
-        "node": ">=4"
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "node_modules/tempy": {
-      "version": "0.2.1",
-      "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.2.1.tgz",
-      "integrity": "sha512-LB83o9bfZGrntdqPuRdanIVCPReam9SOZKW0fOy5I9X3A854GGWi0tjCqoXEk84XIEYBc/x9Hq3EFop/H5wJaw==",
+    "node_modules/p-locate": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+      "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+      "dev": true,
       "dependencies": {
-        "temp-dir": "^1.0.0",
-        "unique-string": "^1.0.0"
+        "p-limit": "^3.0.2"
       },
       "engines": {
-        "node": ">=4"
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "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=",
+    "node_modules/parent-module": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+      "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+      "dev": true,
       "dependencies": {
-        "utf8-byte-length": "^1.0.1"
+        "callsites": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=6"
       }
     },
-    "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/path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
       }
     },
-    "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"
-      },
+    "node_modules/path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+      "dev": true,
       "engines": {
-        "node": ">=4"
+        "node": ">=0.10.0"
       }
     },
-    "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/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
     },
-    "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"
+    "node_modules/prelude-ls": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+      "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.8.0"
       }
-    }
-  },
-  "dependencies": {
-    "clone": {
-      "version": "1.0.4",
-      "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
-      "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4="
     },
-    "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/punycode": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz",
+      "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
     },
-    "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="
+    "node_modules/queue-microtask": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+      "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
     },
-    "defaults": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz",
-      "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=",
-      "requires": {
-        "clone": "^1.0.2"
+    "node_modules/resolve-from": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+      "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
       }
     },
-    "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/reusify": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+      "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+      "dev": true,
+      "engines": {
+        "iojs": ">=1.0.0",
+        "node": ">=0.10.0"
+      }
     },
-    "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=="
+    "node_modules/rimraf": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+      "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+      "dev": true,
+      "dependencies": {
+        "glob": "^7.1.3"
+      },
+      "bin": {
+        "rimraf": "bin.js"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
     },
-    "is-wsl": {
-      "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"
+    "node_modules/run-parallel": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+      "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "dependencies": {
+        "queue-microtask": "^1.2.2"
       }
     },
-    "minimist": {
-      "version": "1.2.5",
-      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
-      "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
+    "node_modules/sanitize-filename": {
+      "version": "1.6.3",
+      "license": "WTFPL OR ISC",
+      "dependencies": {
+        "truncate-utf8-bytes": "^1.0.0"
+      }
     },
-    "mkdirp": {
-      "version": "0.5.5",
-      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz",
-      "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==",
-      "requires": {
-        "minimist": "^1.2.5"
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "dev": true,
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
       }
     },
-    "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/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
     },
-    "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=="
+    "node_modules/shell-escape": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz",
+      "integrity": "sha512-uRRBT2MfEOyxuECseCZd28jC1AJ8hmqqneWQ4VWUTgCAFvb3wKU1jLqj6egC4Exrr88ogg3dp+zroH4wJuaXzw=="
     },
-    "node-fetch": {
-      "version": "2.6.0",
-      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz",
-      "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA=="
+    "node_modules/shortid": {
+      "version": "2.2.15",
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^2.1.0"
+      }
     },
-    "open": {
-      "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"
+    "node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
       }
     },
-    "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==",
-      "requires": {
-        "truncate-utf8-bytes": "^1.0.0"
+    "node_modules/strip-json-comments": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+      "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "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"
+    "node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
       }
     },
-    "temp-dir": {
+    "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="
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
     },
-    "tempy": {
+    "node_modules/tempy": {
       "version": "0.2.1",
-      "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.2.1.tgz",
-      "integrity": "sha512-LB83o9bfZGrntdqPuRdanIVCPReam9SOZKW0fOy5I9X3A854GGWi0tjCqoXEk84XIEYBc/x9Hq3EFop/H5wJaw==",
-      "requires": {
+      "license": "MIT",
+      "dependencies": {
         "temp-dir": "^1.0.0",
         "unique-string": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=4"
       }
     },
-    "truncate-utf8-bytes": {
+    "node_modules/text-table": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+      "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
+      "dev": true
+    },
+    "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=",
-      "requires": {
+      "license": "WTFPL",
+      "dependencies": {
         "utf8-byte-length": "^1.0.1"
       }
     },
-    "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==",
-      "requires": {
+    "node_modules/tui-lib": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.4.0.tgz",
+      "integrity": "sha512-P7PgQHWNK8yVlWZbWm7XLFwirkzQzKNYkhle2YYzj1Ba7fDuh5CITDLvogKFmZSC7RiBC4Y2+2uBpNcRAf1gwQ==",
+      "dependencies": {
+        "natural-orderby": "^3.0.2",
         "wcwidth": "^1.0.1"
       }
     },
-    "unique-string": {
+    "node_modules/tui-lib/node_modules/natural-orderby": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-3.0.2.tgz",
+      "integrity": "sha512-x7ZdOwBxZCEm9MM7+eQCjkrNLrW3rkBKNHVr78zbtqnMGVNlnDi6C/eUEYgxHNrcbu0ymvjzcwIL/6H1iHri9g==",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/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==",
+      "dependencies": {
+        "tui-lib": "^0.1.1"
+      }
+    },
+    "node_modules/tui-text-editor/node_modules/tui-lib": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.1.1.tgz",
+      "integrity": "sha512-QAE4axNCJ42IZSNnc2pLOkFtzHqYFgenDyw88JHHRNd8PXTVO8+JIpJArpgAguopd4MmoYaJbreze0BHoWMXfA==",
+      "dependencies": {
+        "wcwidth": "^1.0.1",
+        "word-wrap": "^1.2.3"
+      }
+    },
+    "node_modules/type-check": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+      "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+      "dev": true,
+      "dependencies": {
+        "prelude-ls": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/type-fest": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+      "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/unique-string": {
       "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz",
-      "integrity": "sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=",
-      "requires": {
+      "license": "MIT",
+      "dependencies": {
         "crypto-random-string": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/uri-js": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+      "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+      "dev": true,
+      "dependencies": {
+        "punycode": "^2.1.0"
       }
     },
-    "utf8-byte-length": {
+    "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="
+      "license": "WTFPL"
     },
-    "wcwidth": {
+    "node_modules/wcwidth": {
       "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
-      "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=",
-      "requires": {
+      "license": "MIT",
+      "dependencies": {
         "defaults": "^1.0.3"
       }
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "dev": true,
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/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==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "dev": true
+    },
+    "node_modules/yocto-queue": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+      "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
     }
   }
 }
diff --git a/package.json b/package.json
index 1cbcb9c..c1630a8 100644
--- a/package.json
+++ b/package.json
@@ -8,16 +8,23 @@
   },
   "author": "",
   "license": "GPL-3.0",
+  "type": "module",
   "dependencies": {
     "command-exists": "^1.2.9",
     "expand-home-dir": "0.0.3",
-    "mkdirp": "^0.5.5",
+    "mkdirp": "^3.0.1",
     "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"
+    "shell-escape": "^0.2.0",
+    "tempy": "^0.2.1",
+    "tui-lib": "^0.4.0",
+    "tui-text-editor": "^0.3.1"
+  },
+  "devDependencies": {
+    "eslint": "^8.40.0"
   }
 }
diff --git a/players.js b/players.js
index dde1fbf..b3d7315 100644
--- a/players.js
+++ b/players.js
@@ -1,15 +1,22 @@
 // stolen from http-music
 
-const { spawn } = require('child_process')
-const { commandExists, killProcess, getTimeStrings } = require('./general-util')
-const EventEmitter = require('events')
-const Socat = require('./socat')
-const fs = require('fs')
-const util = require('util')
-
-const unlink = util.promisify(fs.unlink)
-
-class Player extends EventEmitter {
+import {
+  commandExists,
+  killProcess,
+  getTimeStrings,
+  getTimeStringsFromSec,
+} from './general-util.js'
+
+import {spawn} from 'node:child_process'
+import {statSync} from 'node:fs'
+import {unlink} from 'node:fs/promises'
+import EventEmitter from 'node:events'
+import path from 'node:path'
+import url from 'node:url'
+
+import Socat from './socat.js'
+
+export class Player extends EventEmitter {
   constructor(processOptions = []) {
     super()
 
@@ -37,13 +44,14 @@ class Player extends EventEmitter {
     return this._process
   }
 
-  playFile(file) {}
-  seekAhead(secs) {}
-  seekBack(secs) {}
-  seekTo(timeInSecs) {}
-  volUp(amount) {}
-  volDown(amount) {}
-  setVolume(value) {}
+  playFile(_file, _startTime) {}
+  seekAhead(_secs) {}
+  seekBack(_secs) {}
+  seekTo(_timeInSecs) {}
+  seekToStart() {}
+  volUp(_amount) {}
+  volDown(_amount) {}
+  setVolume(_value) {}
   updateVolume() {}
   togglePause() {}
   toggleLoop() {}
@@ -86,24 +94,44 @@ class Player extends EventEmitter {
   }
 }
 
-module.exports.MPVPlayer = class extends Player {
-  getMPVOptions(file) {
-    const opts = ['--no-video', file]
+export class MPVPlayer extends Player {
+  // The more powerful MPV player. MPV is virtually impossible for a human
+  // being to install; if you're having trouble with it, try the SoX player.
+
+  getMPVOptions(file, startTime) {
+    const opts = [
+      `--term-status-msg='${this.getMPVStatusMessage()}'`,
+      '--no-video',
+      file
+    ]
+
     if (this.isLooping) {
       opts.unshift('--loop')
     }
+
     if (this.isPaused) {
       opts.unshift('--pause')
     }
+
+    if (startTime) {
+      opts.unshift('--start=' + startTime)
+    }
+
     opts.unshift('--volume=' + this.volume * this.volumeMultiplier)
+
     return opts
   }
 
-  playFile(file) {
-    // The more powerful MPV player. MPV is virtually impossible for a human
-    // being to install; if you're having trouble with it, try the SoX player.
+  getMPVStatusMessage() {
+    // Note: This function shouldn't include any single-quotes! It probably
+    // (NOTE: PROBABLY) wouldn't cause any security issues, but it will break
+    // --term-status-msg parsing and might keep mpv from starting at all.
+
+    return '${=time-pos} ${=duration} ${=percent-pos}'
+  }
 
-    this.process = spawn('mpv', this.getMPVOptions(file).concat(this.processOptions))
+  playFile(file, startTime) {
+    this.process = spawn('mpv', this.getMPVOptions(file, startTime).concat(this.processOptions))
 
     let lastPercent = 0
 
@@ -113,14 +141,14 @@ module.exports.MPVPlayer = class extends Player {
       }
 
       const match = data.toString().match(
-        /(..):(..):(..) \/ (..):(..):(..) \(([0-9]+)%\)/
+        /([0-9.]+) ([0-9.]+) ([0-9.]+)/
       )
 
       if (match) {
         const [
-          curHour, curMin, curSec, // ##:##:##
-          lenHour, lenMin, lenSec, // ##:##:##
-          percent // ###%
+          curSecTotal,
+          lenSecTotal,
+          percent
         ] = match.slice(1)
 
         if (parseInt(percent) < lastPercent) {
@@ -133,7 +161,7 @@ module.exports.MPVPlayer = class extends Player {
 
         lastPercent = parseInt(percent)
 
-        this.printStatusLine(getTimeStrings({curHour, curMin, curSec, lenHour, lenMin, lenSec}))
+        this.printStatusLine(getTimeStringsFromSec(curSecTotal, lenSecTotal))
       }
 
       this.updateVolume();
@@ -145,22 +173,21 @@ module.exports.MPVPlayer = class extends Player {
   }
 }
 
-module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer {
-  getMPVOptions(file) {
-    return ['--input-ipc-server=' + this.socat.path, ...super.getMPVOptions(file)]
+export class ControllableMPVPlayer extends MPVPlayer {
+  getMPVOptions(...args) {
+    return ['--input-ipc-server=' + this.socat.path, ...super.getMPVOptions(...args)]
   }
 
-  playFile(file) {
+  playFile(file, startTime) {
     this.removeSocket(this.socketPath)
 
     do {
-      // this.socketPathpath = '/tmp/mtui-socket-' + Math.floor(Math.random() * 10000)
-      this.socketPath = __dirname + '/mtui-socket-' + Math.floor(Math.random() * 10000)
+      this.socketPath = path.join(path.dirname(url.fileURLToPath(import.meta.url)), 'mtui-socket-' + Math.floor(Math.random() * 10000))
     } while (this.existsSync(this.socketPath))
 
     this.socat = new Socat(this.socketPath)
 
-    const mpv = super.playFile(file)
+    const mpv = super.playFile(file, startTime)
 
     mpv.then(() => this.removeSocket(this.socketPath))
 
@@ -169,7 +196,7 @@ module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer {
 
   existsSync(path) {
     try {
-      fs.statSync(path)
+      statSync(path)
       return true
     } catch (error) {
       return false
@@ -194,6 +221,10 @@ module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer {
     this.sendCommand('seek', timeInSecs, 'absolute')
   }
 
+  seekToStart() {
+    this.seekTo(0)
+  }
+
   volUp(amount) {
     this.setVolume(this.volume + amount)
   }
@@ -253,14 +284,19 @@ module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer {
   }
 }
 
-module.exports.SoXPlayer = class extends Player {
-  playFile(file) {
+export class SoXPlayer extends Player {
+  playFile(file, startTime) {
     // SoX's play command is useful for systems that don't have MPV. SoX is
     // much easier to install (and probably more commonly installed, as well).
     // You don't get keyboard controls such as seeking or volume adjusting
     // with SoX, though.
 
-    this.process = spawn('play', [file].concat(this.processOptions))
+    this._file = file
+
+    this.process = spawn('play', [file].concat(
+      this.processOptions,
+      startTime ? ['trim', startTime] : []
+    ))
 
     this.process.stdout.on('data', data => {
       process.stdout.write(data.toString())
@@ -274,14 +310,12 @@ module.exports.SoXPlayer = class extends Player {
           return
         }
 
-        const timeRegex = '([0-9]*):([0-9]*):([0-9]*)\.([0-9]*)'
+        const timeRegex = String.raw`([0-9]*):([0-9]*):([0-9]*)\.([0-9]*)`
         const match = data.toString().trim().match(new RegExp(
           `^In:([0-9.]+%)\\s*${timeRegex}\\s*\\[${timeRegex}\\]`
         ))
 
         if (match) {
-          const percentStr = match[1]
-
           // SoX takes a loooooot of math in order to actually figure out the
           // duration, since it outputs the current time and the remaining time
           // (but not the duration).
@@ -309,19 +343,399 @@ module.exports.SoXPlayer = class extends Player {
 
     return new Promise(resolve => {
       this.process.on('close', () => resolve())
+    }).then(() => {
+      if (this._restartPromise) {
+        const p = this._restartPromise
+        this._restartPromise = null
+        return p
+      }
+    })
+  }
+
+  async seekToStart() {
+    // SoX doesn't support a command interface to interact while playback is
+    // ongoing. However, we can simulate seeking to the start by restarting
+    // playback altogether. We just need to be careful not to resolve the
+    // original playback promise before the new one is complete!
+
+    if (!this._file) {
+      return
+    }
+
+    let resolve = null
+    let reject = null
+
+    // The original call of playFile() will yield control to this promise, which
+    // we bind to the resolve/reject of a new call to playFile().
+    this._restartPromise = new Promise((res, rej) => {
+      resolve = res
+      reject = rej
+    })
+
+    await this.kill()
+
+    this.playFile(this._file).then(resolve, reject)
+  }
+}
+
+export class GhostPlayer extends Player {
+  // The music player which makes believe! This player doesn't actually process
+  // any files nor interface with an underlying binary or API to provide real
+  // sound playback. It just provides all the usual interfaces as best as it
+  // can - simulating playback time by accounting for pause/resume, seeking,
+  // and so on, for example.
+
+  statusInterval = 250
+
+  // This is always a number if a track is "loaded", whether or not paused.
+  // It's null if no track is loaded (aka "stopped"). It's used as the base
+  // for the playback time, if resumed, or directly as the current playback
+  // time, if paused. (Note: time is internally tracked in milliseconds.)
+  #playingFrom = null
+
+  // This is null if no track is "loaded" (aka "stopped") or if paused.
+  // It's used to calculate the current playback time when resumed.
+  #resumedSince = null
+
+  // These are interval/timer identifiers and are null if no track is loaded
+  // or if paused.
+  #statusInterval = null
+  #doneTimeout = null
+  #loopTimeout = null
+
+  // This is a callback which resolves the playFile promise. It exists at the
+  // same time as playingFrom, i.e. while a track is "loaded", whether or not
+  // paused.
+  #resolvePlayFilePromise = null
+
+  // This is reset to null every time a track is started. It can be provided
+  // externally with setDuration(). It's used to control emitting a "done"
+  // event.
+  #duration = null
+
+  setDuration(duration) {
+    // This is a unique interface on GhostPlayer, not found on other players.
+    // Most players inherently know when to resolve playFile thanks to the
+    // child process exiting (or outputting a message) when the input file is
+    // done. GhostPlayer is intended not to operate on actual files at all, so
+    // we couldn't even read duration metadata if we wanted to. So, this extra
+    // interface can be used to provide that data instead!
+
+    if (this.#playingFrom === null) {
+      return
+    }
+
+    if (duration !== null) {
+      if (this.#getPlaybackTime() >= duration * 1000) {
+        // No need to do anything else if we're already done playing according
+        // to the provided duration.
+        this.#donePlaying()
+        return
+      }
+    }
+
+    this.#affectTimeRemaining(() => {
+      this.#duration = duration
+    })
+  }
+
+  playFile(file, startTime = 0) {
+    // This function is public, and might be called without any advance notice,
+    // so clear existing playback info. This also resolves a prior playFile
+    // promise.
+    if (this.#playingFrom !== null) {
+      this.#donePlaying()
+    }
+
+    const promise = new Promise(resolve => {
+      this.#resolvePlayFilePromise = resolve
+    })
+
+    this.#playingFrom = 1000 * startTime
+
+    // It's possible to have paused the player before the next track came up,
+    // in which case playback begins paused.
+    if (!this.isPaused) {
+      this.#resumedSince = Date.now()
+    }
+
+    this.#status()
+    this.#startStatusInterval()
+
+    // We can't start any end-of-track timeouts here because we don't have a
+    // duration yet - we'll instate the appropriate timeout once it's been
+    // provided externally (with setDuration()).
+
+    return promise
+  }
+
+  setPause(paused) {
+    if (!paused && this.isPaused) {
+      this.#resumedSince = Date.now()
+
+      this.#status()
+      this.#startStatusInterval()
+
+      if (this.#duration !== null) {
+        if (this.isLooping) {
+          this.#startLoopTimeout()
+        } else {
+          this.#startDoneTimeout()
+        }
+      }
+    }
+
+    if (paused && !this.isPaused) {
+      this.#playingFrom = this.#getPlaybackTime()
+      this.#resumedSince = null
+
+      this.#status()
+      this.#clearStatusInterval()
+
+      if (this.#duration !== null) {
+        if (this.isLooping) {
+          this.#clearLoopTimeout()
+        } else {
+          this.#clearDoneTimeout()
+        }
+      }
+    }
+
+    this.isPaused = paused
+  }
+
+  togglePause() {
+    this.setPause(!this.isPaused)
+  }
+
+  setLoop(looping) {
+    if (!looping && this.isLooping) {
+      if (this.#duration !== null) {
+        this.#clearLoopTimeout()
+        this.#startDoneTimeout()
+      }
+    }
+
+    if (looping && !this.isLooping) {
+      if (this.#duration !== null) {
+        this.#clearDoneTimeout()
+        this.#startLoopTimeout()
+      }
+    }
+
+    this.isLooping = looping
+  }
+
+  toggleLoop() {
+    this.setLoop(!this.isLooping)
+  }
+
+  seekToStart() {
+    if (this.#playingFrom === null) {
+      return
+    }
+
+    this.seekTo(0)
+  }
+
+  seekAhead(secs) {
+    if (this.#playingFrom === null) {
+      return
+    }
+
+    this.seekTo(this.#getPlaybackTime() / 1000 + secs)
+  }
+
+  seekBack(secs) {
+    if (this.#playingFrom === null) {
+      return
+    }
+
+    this.seekTo(this.#getPlaybackTime() / 1000 - secs)
+  }
+
+  seekTo(timeInSecs) {
+    if (this.#playingFrom === null) {
+      return
+    }
+
+    let seekTime = null
+
+    if (this.#duration !== null && timeInSecs > this.#duration) {
+      // Seeking past the duration of the track either loops it or ends it.
+      if (this.isLooping) {
+        seekTime = 0
+      } else {
+        this.#donePlaying()
+        return
+      }
+    } else if (timeInSecs < 0) {
+      // You can't seek before the beginning of a track!
+      seekTime = 0
+    } else {
+      // Otherwise, just seek to the specified time.
+      seekTime = timeInSecs
+    }
+
+    this.#affectTimeRemaining(() => {
+      if (this.#resumedSince !== null) {
+        // Seeking doesn't pause, but it does functionally reset where we're
+        // measuring playback time from.
+        this.#resumedSince = Date.now()
+      }
+
+      this.#playingFrom = seekTime * 1000
     })
   }
+
+  async kill() {
+    if (this.#playingFrom === null) {
+      return
+    }
+
+    this.#donePlaying()
+  }
+
+  #affectTimeRemaining(callback) {
+    // Changing the time remaining (i.e. the delta between current playback
+    // time and duration) means any timeouts which run when the track ends
+    // need to be reset with the new delta. This function also handily creates
+    // those timeouts in the first place if a duration hadn't been set before.
+
+    if (this.#resumedSince !== null && this.#duration !== null) {
+      // If there was an existing timeout for the end of the track, clear it.
+      // We're going to instate a new one in a moment.
+      if (this.isLooping) {
+        this.#clearLoopTimeout()
+      } else {
+        this.#clearDoneTimeout()
+      }
+    }
+
+    // Do something which will affect the time remaining.
+    callback()
+
+    this.#status()
+
+    if (this.#resumedSince !== null && this.#duration !== null) {
+      // Start a timeout for the (possibly new) end of the track, but only if
+      // we're actually playing!
+      if (this.isLooping) {
+        this.#startLoopTimeout()
+      } else {
+        this.#startDoneTimeout()
+      }
+    }
+  }
+
+  #startStatusInterval() {
+    if (this.#statusInterval !== null) {
+      throw new Error(`Status interval already set (this code shouldn't be reachable!)`)
+    }
+
+    this.#statusInterval = setInterval(() => this.#status(), this.statusInterval)
+  }
+
+  #startDoneTimeout() {
+    if (this.#doneTimeout !== null) {
+      throw new Error(`Done timeout already set (this code shouldn't be reachable!)`)
+    }
+
+    const timeoutInMilliseconds = this.#duration * 1000 - this.#getPlaybackTime()
+    this.#doneTimeout = setTimeout(() => this.#donePlaying(), timeoutInMilliseconds)
+  }
+
+  #startLoopTimeout() {
+    if (this.#loopTimeout !== null) {
+      throw new Error(`Loop timeout already set (this code shouldn't be reachable!)`)
+    }
+
+    const timeoutInMilliseconds = this.#duration * 1000 - this.#getPlaybackTime()
+    this.#loopTimeout = setTimeout(() => this.#loopAtEnd(), timeoutInMilliseconds)
+  }
+
+  #clearStatusInterval() {
+    if (this.#statusInterval === null) {
+      throw new Error(`Status interval not set yet (this code shouldn't be reachable!)`)
+    }
+
+    clearInterval(this.#statusInterval)
+    this.#statusInterval = null
+  }
+
+  #clearDoneTimeout() {
+    if (this.#doneTimeout === null) {
+      throw new Error(`Done timeout not set yet (this code shouldn't be reachable!)`)
+    }
+
+    clearTimeout(this.#doneTimeout)
+    this.#doneTimeout = null
+  }
+
+  #clearLoopTimeout() {
+    if (this.#loopTimeout === null) {
+      throw new Error(`Loop timeout nout set yet (this code shouldn't be reachable!)`)
+    }
+
+    clearTimeout(this.#loopTimeout)
+    this.#loopTimeout = null
+  }
+
+  #status() {
+    // getTimeStringsFromSec supports null duration, so we don't need to
+    // perform a specific check here.
+    const timeInSecs = this.#getPlaybackTime() / 1000
+    this.printStatusLine(getTimeStringsFromSec(timeInSecs, this.#duration))
+  }
+
+  #donePlaying() {
+    if (this.#resumedSince !== null) {
+      this.#clearStatusInterval()
+    }
+
+    // Run this first, while we still have a track "loaded". This ensures the
+    // end-of-track timeouts get cleared appropriately (if they've been set).
+    this.setDuration(null)
+
+    this.#playingFrom = null
+    this.#resumedSince = null
+
+    // No, this doesn't have any spooky tick order errors - resolved promises
+    // always continue on a later tick of the event loop, not the current one.
+    // So the second line here will always happen before any potential future
+    // calls to playFile().
+    this.#resolvePlayFilePromise()
+    this.#resolvePlayFilePromise = null
+  }
+
+  #loopAtEnd() {
+    // Looping is just seeking back to the start! This will also cause the
+    // loop timer to be reinstated (via #affectTimeRemaining).
+    this.seekToStart()
+  }
+
+  #getPlaybackTime() {
+    if (this.#resumedSince === null) {
+      return this.#playingFrom
+    } else {
+      return this.#playingFrom + Date.now() - this.#resumedSince
+    }
+  }
 }
 
-module.exports.getPlayer = async function(name = null, options = []) {
+export async function getPlayer(name = null, options = []) {
+  if (name === 'ghost') {
+    return new GhostPlayer(options)
+  }
+
   if (await commandExists('mpv') && (name === null || name === 'mpv')) {
-    return new module.exports.ControllableMPVPlayer(options)
+    return new ControllableMPVPlayer(options)
   } else if (name === 'mpv') {
     return null
   }
 
   if (await commandExists('play') && (name === null || name === 'sox')) {
-    return new module.exports.SoXPlayer(options)
+    return new SoXPlayer(options)
   } else if (name === 'sox') {
     return null
   }
diff --git a/playlist-utils.js b/playlist-utils.js
index 979c6d6..dd1d8c8 100644
--- a/playlist-utils.js
+++ b/playlist-utils.js
@@ -1,16 +1,12 @@
 'use strict'
 
-const path = require('path')
-const fs = require('fs')
+import path from 'node:path'
 
-const { promisify } = require('util')
-const unlink = promisify(fs.unlink)
+import {shuffleArray} from './general-util.js'
 
-const { shuffleArray } = require('./general-util')
+export const parentSymbol = Symbol('Parent group')
 
-const parentSymbol = Symbol('Parent group')
-
-function updatePlaylistFormat(playlist) {
+export function updatePlaylistFormat(playlist) {
   const defaultPlaylist = {
     options: [],
     items: []
@@ -43,7 +39,7 @@ function updatePlaylistFormat(playlist) {
   return updateGroupFormat(fullPlaylistObj)
 }
 
-function updateGroupFormat(group) {
+export function updateGroupFormat(group) {
   const defaultGroup = {
     name: '',
     items: []
@@ -61,7 +57,7 @@ function updateGroupFormat(group) {
 
   groupObj.items = groupObj.items.map(item => {
     // Check if it's a group; if not, it's probably a track.
-    if (typeof item[1] === 'array' || item.items) {
+    if (Array.isArray(item[1]) || item.items) {
       item = updateGroupFormat(item)
     } else {
       item = updateTrackFormat(item)
@@ -85,7 +81,7 @@ function updateGroupFormat(group) {
   return groupObj
 }
 
-function updateTrackFormat(track) {
+export function updateTrackFormat(track) {
   const defaultTrack = {
     name: '',
     downloaderArg: ''
@@ -106,7 +102,7 @@ function updateTrackFormat(track) {
   return Object.assign(defaultTrack, trackObj)
 }
 
-function cloneGrouplike(grouplike) {
+export function cloneGrouplike(grouplike) {
   const newGrouplike = {
     name: grouplike.name,
     items: grouplike.items.map(item => {
@@ -128,7 +124,7 @@ function cloneGrouplike(grouplike) {
   return newGrouplike
 }
 
-function filterTracks(grouplike, handleTrack) {
+export function filterTracks(grouplike, handleTrack) {
   // Recursively filters every track in the passed grouplike. The track-handler
   // function passed should either return true (to keep a track) or false (to
   // remove the track). After tracks are filtered, groups which contain no
@@ -161,7 +157,7 @@ function filterTracks(grouplike, handleTrack) {
   })
 }
 
-function flattenGrouplike(grouplike) {
+export function flattenGrouplike(grouplike) {
   // Flattens a group-like, taking all of the non-group items (tracks) at all
   // levels in the group tree and returns them as a new group containing those
   // tracks.
@@ -169,7 +165,7 @@ function flattenGrouplike(grouplike) {
   return {items: getFlatTrackList(grouplike)}
 }
 
-function 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.
 
@@ -182,7 +178,7 @@ function getFlatTrackList(grouplike) {
   }).reduce((a, b) => a.concat(b), [])
 }
 
-function getFlatGroupList(grouplike) {
+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.
 
@@ -192,7 +188,7 @@ function getFlatGroupList(grouplike) {
     .reduce((a, b) => a.concat(b), [])
 }
 
-function countTotalTracks(item) {
+export function countTotalTracks(item) {
   // Returns the total number of tracks in a grouplike, including tracks in any
   // descendant groups. Basically the same as flattenGrouplike().items.length.
 
@@ -206,7 +202,7 @@ function countTotalTracks(item) {
   }
 }
 
-function shuffleOrderOfGroups(grouplike) {
+export function shuffleOrderOfGroups(grouplike) {
   // OK, this is opinionated on how it should work, but I think it Makes Sense.
   // Also sorry functional-programming friends, I'm sure this is a horror.
   // (FYI, this is the same as how http-music used to work with shuffle-groups,
@@ -224,12 +220,12 @@ function shuffleOrderOfGroups(grouplike) {
   return {items: shuffleArray(items)}
 }
 
-function reverseOrderOfGroups(grouplike) {
+export function reverseOrderOfGroups(grouplike) {
   const { items } = collapseGrouplike(grouplike)
   return {items: items.reverse()}
 }
 
-function collectGrouplikeChildren(grouplike, filter = null) {
+export function collectGrouplikeChildren(grouplike, filter = null) {
   // Collects all descendants of a grouplike into a single flat array.
   // Can be passed a filter function, which will decide whether or not to add
   // an item to the return array. However, note that all descendants will be
@@ -252,7 +248,7 @@ function collectGrouplikeChildren(grouplike, filter = null) {
   return items
 }
 
-function partiallyFlattenGrouplike(grouplike, resultDepth) {
+export function partiallyFlattenGrouplike(grouplike, resultDepth) {
   // Flattens a grouplike so that it is never more than a given number of
   // groups deep, INCLUDING the "top" group -- e.g. a resultDepth of 2
   // means that there can be one level of groups remaining in the resulting
@@ -273,7 +269,7 @@ function partiallyFlattenGrouplike(grouplike, resultDepth) {
   return {items}
 }
 
-function collapseGrouplike(grouplike) {
+export function collapseGrouplike(grouplike) {
   // Similar to partiallyFlattenGrouplike, but doesn't discard the individual
   // ordering of tracks; rather, it just collapses them all to one level.
 
@@ -299,7 +295,7 @@ function collapseGrouplike(grouplike) {
   return {items: ret}
 }
 
-function filterGrouplikeByProperty(grouplike, property, value) {
+export function filterGrouplikeByProperty(grouplike, property, value) {
   // Returns a copy of the original grouplike, only keeping tracks with the
   // given property-value pair. (If the track's value for the given property
   // is an array, this will check if that array includes the given value.)
@@ -329,13 +325,13 @@ function filterGrouplikeByProperty(grouplike, property, value) {
   })
 }
 
-function filterPlaylistByPathString(playlist, pathString) {
+export function filterPlaylistByPathString(playlist, pathString) {
   // Calls filterGroupContentsByPath, taking an unparsed path string.
 
   return filterGrouplikeByPath(playlist, parsePathString(pathString))
 }
 
-function filterGrouplikeByPath(grouplike, pathParts) {
+export function filterGrouplikeByPath(grouplike, pathParts) {
   // Finds a group by following the given group path and returns it. If the
   // function encounters an item in the group path that is not found, it logs
   // a warning message and returns the group found up to that point. If the
@@ -386,13 +382,13 @@ function filterGrouplikeByPath(grouplike, pathParts) {
   }
 }
 
-function removeGroupByPathString(playlist, pathString) {
+export function removeGroupByPathString(playlist, pathString) {
   // Calls removeGroupByPath, taking a path string, rather than a parsed path.
 
   return removeGroupByPath(playlist, parsePathString(pathString))
 }
 
-function removeGroupByPath(playlist, pathParts) {
+export function removeGroupByPath(playlist, pathParts) {
   // Removes the group at the given path from the given playlist.
 
   const groupToRemove = filterGrouplikeByPath(playlist, pathParts)
@@ -433,7 +429,7 @@ function removeGroupByPath(playlist, pathParts) {
   }
 }
 
-function getPlaylistTreeString(playlist, showTracks = false) {
+export function getPlaylistTreeString(playlist, showTracks = false) {
   function recursive(group) {
     const groups = group.items.filter(x => isGroup(x))
     const nonGroups = group.items.filter(x => !isGroup(x))
@@ -469,7 +465,7 @@ function getPlaylistTreeString(playlist, showTracks = false) {
   return recursive(playlist)
 }
 
-function getItemPath(item) {
+export function getItemPath(item) {
   if (item[parentSymbol]) {
     return [...getItemPath(item[parentSymbol]), item]
   } else {
@@ -477,7 +473,7 @@ function getItemPath(item) {
   }
 }
 
-function getItemPathString(item) {
+export function getItemPathString(item) {
   // Gets the playlist path of an item by following its parent chain.
   //
   // Returns a string in format Foo/Bar/Baz, where Foo and Bar are group
@@ -504,12 +500,12 @@ function getItemPathString(item) {
   }
 }
 
-function parsePathString(pathString) {
+export function parsePathString(pathString) {
   const pathParts = pathString.split('/').filter(item => item.length)
   return pathParts
 }
 
-function getTrackIndexInParent(track) {
+export function getTrackIndexInParent(track) {
   if (parentSymbol in track === false) {
     throw new Error(
       'getTrackIndexInParent called with a track that has no parent!'
@@ -520,6 +516,11 @@ function getTrackIndexInParent(track) {
 
   let i = 0, foundTrack = false;
   for (; i < parent.items.length; i++) {
+    // TODO: Port isSameTrack from http-music, if it makes sense - doing
+    // so involves porting the [oldSymbol] property on all tracks and groups,
+    // so may or may not be the right call. This function isn't used anywhere
+    // in mtui so it'll take a little extra investigation.
+    /* eslint-disable-next-line no-undef */
     if (isSameTrack(track, parent.items[i])) {
       foundTrack = true
       break
@@ -534,14 +535,14 @@ function getTrackIndexInParent(track) {
 }
 
 const nameWithoutTrackNumberSymbol = Symbol('Cached name without track number')
-function getNameWithoutTrackNumber(track) {
+export function getNameWithoutTrackNumber(track) {
   // A "part" is a series of numeric digits, separated from other parts by
   // whitespace, dashes, and dots, always preceding either the first non-
   // numeric/separator character or (if there are no such characters) the
   // first word (i.e. last whitespace).
   const getNumberOfParts = ({ name }) => {
-    name = name.replace(/^[\-\s.]+$/, '')
-    const match = name.match(/[^0-9\-\s.]/)
+    name = name.replace(/^[-\s.]+$/, '')
+    const match = name.match(/[^0-9-\s.]/)
     if (match) {
       if (match.index === 0) {
         return 0
@@ -553,12 +554,12 @@ function getNameWithoutTrackNumber(track) {
     } else {
       return 0
     }
-    name = name.replace(/[\-\s.]+$/, '')
-    return name.split(/[\-\s.]+/g).length
+    name = name.replace(/[-\s.]+$/, '')
+    return name.split(/[-\s.]+/g).length
   }
 
   const removeParts = (name, numParts) => {
-    const regex = new RegExp(String.raw`[\-\s.]{0,}([0-9]+[\-\s.]+){${numParts},${numParts}}`)
+    const regex = new RegExp(String.raw`[-\s.]{0,}([0-9]+[-\s.]+){${numParts},${numParts}}`)
     return track.name.replace(regex, '')
   }
 
@@ -606,24 +607,24 @@ function getNameWithoutTrackNumber(track) {
   }
 }
 
-function isGroup(obj) {
+export function isGroup(obj) {
   return !!(obj && obj.items)
 }
 
-function isTrack(obj) {
+export function isTrack(obj) {
   return !!(obj && obj.downloaderArg)
 }
 
-function isPlayable(obj) {
+export function isPlayable(obj) {
   return isGroup(obj) || isTrack(obj)
 }
 
-function isOpenable(obj) {
+export function isOpenable(obj) {
   return !!(obj && obj.url)
 }
 
 
-function searchForItem(grouplike, value, preferredStartIndex = -1) {
+export function searchForItem(grouplike, value, preferredStartIndex = -1) {
   if (value.length) {
     // We prioritize searching past the index that the user opened the jump
     // element from (oldFocusedIndex). This is so that it's more practical
@@ -663,12 +664,12 @@ function searchForItem(grouplike, value, preferredStartIndex = -1) {
   return null
 }
 
-function getCorrespondingFileForItem(item, extension) {
+export function getCorrespondingFileForItem(item, extension) {
   if (!(item && item.url)) {
     return null
   }
 
-  const checkExtension = item => item.url && path.extname(item.url) === extension
+  const checkExtension = item => item.url && item.url.endsWith(extension)
 
   if (isPlayable(item)) {
     const parent = item[parentSymbol]
@@ -688,7 +689,7 @@ function getCorrespondingFileForItem(item, extension) {
   return null
 }
 
-function getCorrespondingPlayableForFile(item) {
+export function getCorrespondingPlayableForFile(item) {
   if (!(item && item.url)) {
     return null
   }
@@ -707,7 +708,7 @@ function getCorrespondingPlayableForFile(item) {
   return parent.items.find(item => isPlayable(item) && path.basename(item.url, path.extname(item.url)) === basename)
 }
 
-function getPathScore(path1, path2) {
+export 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
@@ -836,7 +837,7 @@ function getPathScore(path1, path2) {
   return scores.reduce((a, b) => a < b ? a : b)
 }
 
-function getNameScore(name1, name2) {
+export function getNameScore(name1, name2) {
   // Pretty simple algorithm here: we're looking for the longest continuous
   // series of words which is shared between both names. The score is the
   // length of that series, so a higher score is better (and a zero score
@@ -902,7 +903,7 @@ function getNameScore(name1, name2) {
   )
 }
 
-function findItemObject(referenceData, possibleChoices) {
+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
   // item objects when reloading a playlist (see serialized-backend.js). It's
@@ -977,88 +978,23 @@ function findItemObject(referenceData, possibleChoices) {
   return mostResembles.item
 }
 
-module.exports = {
-  parentSymbol,
-  updatePlaylistFormat, updateGroupFormat, updateTrackFormat,
-  cloneGrouplike,
-  filterTracks,
-  flattenGrouplike,
-  getFlatTrackList,
-  getFlatGroupList,
-  countTotalTracks,
-  shuffleOrderOfGroups,
-  reverseOrderOfGroups,
-  partiallyFlattenGrouplike, collapseGrouplike,
-  filterGrouplikeByProperty,
-  filterPlaylistByPathString, filterGrouplikeByPath,
-  removeGroupByPathString, removeGroupByPath,
-  getPlaylistTreeString,
-  getItemPath, getItemPathString,
-  parsePathString,
-  getTrackIndexInParent,
-  getNameWithoutTrackNumber,
-  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(getNameScore('C418 - Vlem', 'Vlem'))
-  console.log(getNameScore('glimmer', 'glimmer'))
-  console.log(getNameScore('C418 - Vlem', 'covet - glimmer'))
-  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'}
-          ]}
-        ]}
-      ]})
-    )
-  ))
-
-  {
-    const group = updateGroupFormat({items: [
-      {name: '- 1.01 Hello World 425', downloaderArg: 'x'},
-      {name: '1.02 Aww Yeah 371', downloaderArg: 'x'},
-      {name: ' 1.03 Here Goes 472', downloaderArg: 'x'}
-    ]})
+export function walkSharedStructure(modelGrouplike, ...additionalGrouplikesAndCallback) {
+  // Recursively traverse (aka "walk") a model grouplike and follow the same
+  // path through one or more additional grouplikes, running a callback with
+  // the item at that path from each of the grouplikes (model and additional).
 
-    for (let i = 0; i < group.items.length; i++) {
-      console.log(group.items[i].name, '->', getNameWithoutTrackNumber(group.items[i]))
-    }
-  }
+  const additionalGrouplikes = additionalGrouplikesAndFunction.slice(0, -1)
+  const callback = additionalGrouplikesAndCallback[additionalGrouplikesAndFunction.length - 1]
 
-  {
-    const group = updateGroupFormat({items: [
-      {name: 'BAM #1', downloaderArg: 'x'},
-      {name: 'BAM #2', downloaderArg: 'x'},
-      {name: 'BAM #3.1 - no', downloaderArg: 'x'}
-    ]})
+  const recursive = (model, ...additional) => {
+    for (let i = 0; i < model.items.length; i++) {
+      const modelItem = model.items[i]
+      const additionalItems = additional.map(a => a.items[i])
+      callback(modelItem, ...additionalItems)
 
-    for (let i = 0; i < group.items.length; i++) {
-      console.log(group.items[i].name, '->', getNameWithoutTrackNumber(group.items[i]))
+      if (isGroup(modelItem)) {
+        recursive(modelItem, ...additionalItems)
+      }
     }
   }
 }
diff --git a/record-store.js b/record-store.js
index 80c8d3a..2686457 100644
--- a/record-store.js
+++ b/record-store.js
@@ -1,6 +1,6 @@
 const recordSymbolKey = Symbol('Record symbol')
 
-module.exports = class RecordStore {
+export default class RecordStore {
   constructor() {
     // Each track (or whatever) gets a symbol which is used as a key here to
     // store more information.
diff --git a/serialized-backend.js b/serialized-backend.js
index a3f02fa..4b3f845 100644
--- a/serialized-backend.js
+++ b/serialized-backend.js
@@ -21,7 +21,7 @@
 
 'use strict'
 
-const {
+import {
   isGroup,
   isTrack,
   findItemObject,
@@ -29,7 +29,7 @@ const {
   getFlatGroupList,
   getFlatTrackList,
   getItemPath
-} = require('./playlist-utils')
+} from './playlist-utils.js'
 
 const referenceDataSymbol = Symbol('Restored reference data')
 
@@ -43,7 +43,7 @@ function getPlayerInfo(queuePlayer) {
   }
 }
 
-function saveBackend(backend) {
+export function saveBackend(backend) {
   return {
     queuePlayers: backend.queuePlayers.map(QP => ({
       id: QP.id,
@@ -55,7 +55,9 @@ function saveBackend(backend) {
   }
 }
 
-async function restoreBackend(backend, data) {
+export async function restoreBackend(backend, data) {
+  // console.log('restoring backend:', data)
+
   if (data.queuePlayers) {
     if (data.queuePlayers.length === 0) {
       return
@@ -90,18 +92,10 @@ async function restoreBackend(backend, data) {
 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()
-      }
-    }
-  })
+  QP.play(playedTrack, playerInfo.time || 0, playerInfo.isPaused)
 }
 
-function updateRestoredTracksUsingPlaylists(backend, playlists) {
+export 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.
@@ -159,7 +153,7 @@ function updateRestoredTracksUsingPlaylists(backend, playlists) {
   }
 }
 
-function saveItemReference(item) {
+export function saveItemReference(item) {
   // Utility function to generate reference data for a track or grouplike,
   // according to the format taken by findItemObject.
 
@@ -182,7 +176,7 @@ function saveItemReference(item) {
   }
 }
 
-function restoreNewItem(referenceData, playlists) {
+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.
@@ -227,19 +221,10 @@ function restoreNewItem(referenceData, playlists) {
   }
 }
 
-function getWaitingTrackData(queuePlayer) {
+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
   // 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/smart-playlist.js b/smart-playlist.js
index 19294db..c8abf62 100644
--- a/smart-playlist.js
+++ b/smart-playlist.js
@@ -1,7 +1,7 @@
-const { getCrawlerByName } = require('./crawlers')
-const { isGroup, filterTracks, sourceSymbol, updatePlaylistFormat } = require('./playlist-utils')
+import {getCrawlerByName} from './crawlers.js'
+import {filterTracks, isGroup, updatePlaylistFormat} from './playlist-utils.js'
 
-async function processSmartPlaylist(item, topItem = true) {
+export default async function processSmartPlaylist(item, topItem = true) {
   // Object.assign is used so that we keep original properties, e.g. "name"
   // or "apply". (It's also used so we return copies of original objects.)
 
@@ -133,5 +133,3 @@ async function processSmartPlaylist(item, topItem = true) {
     return newItem
   }
 }
-
-module.exports = processSmartPlaylist
diff --git a/socat.js b/socat.js
index 8871c7e..a465a73 100644
--- a/socat.js
+++ b/socat.js
@@ -2,16 +2,14 @@
 // Assumes access to the `socat` command as a child process; if it's not
 // present, it will fall back to just writing to the specified file.
 
-const EventEmitter = require('events')
-const { spawn } = require('child_process')
-const { killProcess, commandExists } = require('./general-util')
-const { promisify } = require('util')
-const fs = require('fs')
-const path = require('path')
+import {spawn} from 'node:child_process'
+import {writeFile} from 'node:fs/promises'
+import EventEmitter from 'node:events'
+import path from 'node:path'
 
-const writeFile = promisify(fs.writeFile)
+import {killProcess, commandExists} from './general-util.js'
 
-module.exports = class Socat extends EventEmitter {
+export default class Socat extends EventEmitter {
   constructor(path) {
     super()
     this.setPath(path)
@@ -30,7 +28,7 @@ module.exports = class Socat extends EventEmitter {
       this.subprocess.on('close', () => {
         this.subprocess = null
       })
-      this.subprocess.stdin.on('error', err => {
+      this.subprocess.stdin.on('error', () => {
         this.stop()
       })
     }
@@ -69,7 +67,7 @@ module.exports = class Socat extends EventEmitter {
       }
     } else {
       try {
-        await writeFile(path.resolve(__dirname, this.path), message + '\r\n')
+        await writeFile(path.resolve(process.cwd(), this.path), message + '\r\n')
       } catch (error) {
         // :shrug: We tried!
         // -- It's possible to get here if the specified path isn't an actual
diff --git a/socket.js b/socket.js
index bc35c76..5c54bbc 100644
--- a/socket.js
+++ b/socket.js
@@ -17,39 +17,41 @@
 // library from there. This would be handy for people with a VPN with its own
 // hostname and firewall protections!
 
-'use strict' // single quotes & no semicolons time babey
+// 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 originalSymbol = Symbol('Original item')
+import EventEmitter from 'node:events'
+import net from 'node:net'
 
-const EventEmitter = require('events')
-const net = require('net')
-const shortid = require('shortid')
-
-const {
-  saveBackend,
-  restoreBackend,
-  saveItemReference,
-  restoreNewItem,
-  updateRestoredTracksUsingPlaylists
-} = require('./serialized-backend')
+import shortid from 'shortid'
 
-const {
+import {
   getTimeStringsFromSec,
-  silenceEvents
-} = require('./general-util')
+  parseWithoutPrototype,
+  silenceEvents,
+} from './general-util.js'
 
-const {
+import {
   parentSymbol,
   updateGroupFormat,
   updateTrackFormat,
   isTrack,
-  isGroup
-} = require('./playlist-utils')
+  isGroup,
+} from './playlist-utils.js'
+
+import {
+  restoreBackend,
+  restoreNewItem,
+  saveBackend,
+  saveItemReference,
+  updateRestoredTracksUsingPlaylists,
+} from './serialized-backend.js'
+
+// 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)'
+
+export const originalSymbol = Symbol('Original item')
 
 function serializePartySource(item) {
   // Turn an item into a sanitized, compact format for sharing with the server
@@ -67,7 +69,7 @@ function serializePartySource(item) {
   }
 }
 
-function deserializePartySource(source) {
+function deserializePartySource(source, parent = null) {
   // Reconstruct a party source into the ordinary group/track format.
 
   const recursive = source => {
@@ -81,9 +83,16 @@ function deserializePartySource(source) {
   }
 
   const top = recursive(source)
-  return (isGroup(top)
+
+  const item = (isGroup(top)
     ? updateGroupFormat(top)
     : updateTrackFormat(top))
+
+  if (parent) {
+    item[parentSymbol] = parent
+  }
+
+  return item
 }
 
 function serializeCommandToData(command) {
@@ -94,7 +103,11 @@ function serializeCommandToData(command) {
 function deserializeDataToCommand(data) {
   // Turn data received from a socket into a command that can be processed as
   // an action to apply to the mtui backend.
-  return JSON.parse(data)
+  return parseWithoutPrototype(data)
+}
+
+function namePartySources(nickname) {
+  return `Party Sources - ${nickname}`
 }
 
 function isItemRef(ref) {
@@ -144,26 +157,33 @@ function validateCommand(command) {
   switch (command.sender) {
     case 'server':
       switch (command.code) {
-        case 'initialize-backend':
-          return typeof command.backend === 'object'
-        case 'set-socket-id':
+        case 'initialize party':
+          return (
+            typeof command.backend === 'object' &&
+            typeof command.socketInfo === 'object' &&
+            Object.values(command.socketInfo).every(info => (
+              typeof info.nickname === 'string' &&
+              Array.isArray(info.sharedSources)
+            ))
+          )
+        case 'set socket id':
           return typeof command.socketId === 'string'
       }
       // No break here; servers can send commands which typically come from
       // clients too.
     case 'client':
       switch (command.code) {
-        case 'announce-join':
+        case 'announce join':
           return true
-        case 'clear-queue':
+        case 'clear queue':
           return typeof command.queuePlayer === 'string'
-        case 'clear-queue-past':
-        case 'clear-queue-up-to':
+        case 'clear queue past':
+        case 'clear queue up to':
           return (
             typeof command.queuePlayer === 'string' &&
             isItemRef(command.track)
           )
-        case 'distribute-queue':
+        case 'distribute queue':
           return (
             typeof command.queuePlayer === 'string' &&
             isItemRef(command.topItem) &&
@@ -199,26 +219,26 @@ function validateCommand(command) {
               )
             ))
           )
-        case 'restore-queue':
+        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':
+        case 'seek to':
           return (
             typeof command.queuePlayer === 'string' &&
             typeof command.time === 'number'
           )
-        case 'set-nickname':
+        case 'set nickname':
           return (
             typeof command.nickname === 'string' &&
             typeof command.oldNickname === 'string' &&
             command.nickname.length >= 1 &&
             command.nickname.length <= 12
           )
-        case 'set-pause':
+        case 'set pause':
           return (
             typeof command.queuePlayer === 'string' &&
             typeof command.paused === 'boolean' &&
@@ -227,21 +247,25 @@ function validateCommand(command) {
               command.sender === 'server'
             ) || !command.startingTrack
           )
-        case 'share-with-party':
+        case 'added queue player':
+          return (
+            typeof command.id === 'string'
+          )
+        case 'share with party':
           return (
             typeof command.item === 'string' ||
             Array.isArray(command.item)
           )
         case 'status':
           return (
-            command.status === 'done-playing' ||
+            command.status === 'done playing' ||
             (
-              command.status === 'ready-to-resume' &&
+              command.status === 'ready to resume' &&
               typeof command.queuePlayer === 'string'
             ) ||
-            command.status === 'sync-playback'
+            command.status === 'sync playback'
           )
-        case 'stop-playing':
+        case 'stop playing':
           return typeof command.queuePlayer === 'string'
         case 'unqueue':
           return (
@@ -278,7 +302,7 @@ function perLine(handleLine) {
   }
 }
 
-function makeSocketServer() {
+export 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
@@ -290,23 +314,36 @@ function makeSocketServer() {
   const server = new net.Server()
   const socketMap = Object.create(null)
 
+  // Keeps track of details to share with newly joining sockets for
+  // synchronization.
+  const socketInfoMap = Object.create(null)
+
   server.canonicalBackend = null
 
   // <variable> -> queue player id -> array: socket
-  const readyToResume = {}
-  const donePlaying = {}
+  const readyToResume = Object.create(null)
+  const donePlaying = Object.create(null)
 
   server.on('connection', socket => {
     const socketId = shortid.generate()
 
-    socketMap[socketId] = socket
+    const socketInfo = {
+      hasAnnouncedJoin: false,
+      nickname: DEFAULT_NICKNAME,
+
+      // Unlike in client code, this isn't an array of actual playlist items;
+      // rather, it's the intermediary format used when transferring between
+      // client and server.
+      sharedSources: []
+    }
 
-    let hasAnnouncedJoin = false
-    let nickname = DEFAULT_NICKNAME
+    socketMap[socketId] = socket
+    socketInfoMap[socketId] = socketInfo
 
     socket.on('close', () => {
       if (socketId in socketMap) {
         delete socketMap[socketId]
+        delete socketInfoMap[socketId]
       }
     })
 
@@ -322,7 +359,7 @@ function makeSocketServer() {
 
       command.sender = 'client'
       command.senderSocketId = socketId
-      command.senderNickname = nickname
+      command.senderNickname = socketInfo.nickname
 
       if (!validateCommand(command)) {
         return
@@ -331,10 +368,10 @@ function makeSocketServer() {
       // If the socket hasn't announced its joining yet, it only has access to
       // a few commands.
 
-      if (!hasAnnouncedJoin) {
+      if (!socketInfo.hasAnnouncedJoin) {
         if (![
-          'announce-join',
-          'set-nickname'
+          'announce join',
+          'set nickname'
         ].includes(command.code)) {
           return
         }
@@ -345,7 +382,7 @@ function makeSocketServer() {
 
       if (command.code === 'status') {
         switch (command.status) {
-          case 'done-playing': {
+          case 'done playing': {
             const doneSockets = donePlaying[command.queuePlayer]
             if (doneSockets && !doneSockets.includes(socketId)) {
               doneSockets.push(socketId)
@@ -359,17 +396,15 @@ function makeSocketServer() {
             }
             break
           }
-          case 'ready-to-resume': {
+          case 'ready to resume': {
             const readySockets = readyToResume[command.queuePlayer]
             if (readySockets && !readySockets.includes(socketId)) {
               readySockets.push(socketId)
               if (readySockets.length === Object.keys(socketMap).length) {
-                const QP = server.canonicalBackend.queuePlayers.find(QP => QP.id === command.queuePlayer)
-                silenceEvents(QP, ['set-pause'], () => QP.setPause(false))
                 for (const socket of Object.values(socketMap)) {
                   socket.write(serializeCommandToData({
                     sender: 'server',
-                    code: 'set-pause',
+                    code: 'set pause',
                     queuePlayer: command.queuePlayer,
                     startingTrack: true,
                     paused: false
@@ -381,18 +416,18 @@ function makeSocketServer() {
             }
             break
           }
-          case 'sync-playback':
+          case 'sync playback':
             for (const QP of server.canonicalBackend.queuePlayers) {
               if (QP.timeData) {
                 socket.write(serializeCommandToData({
                   sender: 'server',
-                  code: 'seek-to',
+                  code: 'seek to',
                   queuePlayer: QP.id,
                   time: QP.timeData.curSecTotal
                 }) + '\n')
                 socket.write(serializeCommandToData({
                   sender: 'server',
-                  code: 'set-pause',
+                  code: 'set pause',
                   queuePlayer: QP.id,
                   startingTrack: true,
                   paused: QP.player.isPaused
@@ -410,26 +445,34 @@ function makeSocketServer() {
         readyToResume[command.queuePlayer] = []
       }
 
-      // If it's a 'set-nickname' command, save the nickname.
+      // 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
+      if (command.code === 'set nickname') {
+        command.oldNickname = socketInfo.nickname
+        command.senderNickname = socketInfo.nickname
+        socketInfo.nickname = command.nickname
+      }
+
+      // If it's a 'share with party' command, keep track of the item being
+      // shared, so we can synchronize newly joining sockets with it.
+
+      if (command.code === 'share with party') {
+        const { sharedSources } = socketInfoMap[socketId]
+        sharedSources.push(command.item)
       }
 
-      // If it's an 'announce-join' command, mark the variable for this!
+      // If it's an 'announce join' command, mark the variable for this!
 
-      if (command.code === 'announce-join') {
-        hasAnnouncedJoin = true;
+      if (command.code === 'announce join') {
+        socketInfo.hasAnnouncedJoin = true;
       }
 
       // If the socket hasn't announced its joining yet, don't relay the
-      // command. (Since hasAnnouncedJoin gets set above, 'announce-join'
-      // will meet this condition.)
+      // command. (Since hasAnnouncedJoin gets set above, 'announce join'
+      // will pass this condition.)
 
-      if (!hasAnnouncedJoin) {
+      if (!socketInfo.hasAnnouncedJoin) {
         return
       }
 
@@ -452,21 +495,22 @@ function makeSocketServer() {
 
     socket.write(serializeCommandToData({
       sender: 'server',
-      code: 'set-socket-id',
+      code: 'set socket id',
       socketId
     }) + '\n')
 
     socket.write(serializeCommandToData({
       sender: 'server',
-      code: 'initialize-backend',
-      backend: savedBackend
+      code: 'initialize party',
+      backend: savedBackend,
+      socketInfo: socketInfoMap
     }) + '\n')
   })
 
   return server
 }
 
-function makeSocketClient() {
+export 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()).
@@ -479,7 +523,7 @@ function makeSocketClient() {
   client.sendCommand = function(command) {
     const data = serializeCommandToData(command)
     client.socket.write(data + '\n')
-    client.emit('sent-command', command)
+    client.emit('sent command', command)
   }
 
   client.socket.on('data', perLine(line => {
@@ -503,22 +547,22 @@ function makeSocketClient() {
   return client
 }
 
-function attachBackendToSocketClient(backend, client) {
+export function attachBackendToSocketClient(backend, client) {
   // All actual logic for instances of the mtui backend interacting with each
   // other through commands lives here.
 
   let hasAnnouncedJoin = false
 
-  const partyGrouplike = {
-    name: `Party Sources - ${client.nickname}`,
+  const sharedSources = {
+    name: namePartySources(client.nickname),
     isPartySources: true,
     items: []
   }
 
-  const partyGrouplikeMap = Object.create(null)
+  const socketInfoMap = Object.create(null)
 
   const getPlaylistSources = () =>
-    partyGrouplike.items.map(item => item[originalSymbol])
+    sharedSources.items.map(item => item[originalSymbol])
 
   backend.setHasAnnouncedJoin(false)
   backend.setAlwaysStartPaused(true)
@@ -541,22 +585,22 @@ function attachBackendToSocketClient(backend, client) {
     let isVerbose = false
 
     switch (command.code) {
-      case 'announce-join':
+      case 'announce join':
         actionmsg = `joined the party`
         break
-      case 'clear-queue':
+      case 'clear queue':
         actionmsg = 'cleared the queue'
         break
-      case 'clear-queue-past':
+      case 'clear queue past':
         actionmsg = `cleared the queue past ${itemToMessage(command.track)}`
         break
-      case 'clear-queue-up-to':
+      case 'clear queue up to':
         actionmsg = `cleared the queue up to ${itemToMessage(command.track)}`
         break
-      case 'distribute-queue':
+      case 'distribute queue':
         actionmsg = `distributed ${itemToMessage(command.topItem)} across the queue ${command.opts.how}`
         break
-      case 'initialize-backend':
+      case 'initialize party':
         return
       case 'play':
         actionmsg = `started playing ${itemToMessage(command.track)}`
@@ -571,52 +615,55 @@ function attachBackendToSocketClient(backend, client) {
         actionmsg = `queued ${itemToMessage(command.topItem)}` + afterMessage
         break
       }
-      case 'restore-queue':
+      case 'restore queue':
         if (command.why === 'shuffle') {
           actionmsg = 'shuffled the queue'
         }
         break
-      case 'share-with-party':
+      case 'share with party':
         // TODO: This isn't an outrageously expensive operation, but it still
         // seems a little unnecessary to deserialize it here if we also do that
         // when actually processing the source?
         actionmsg = `shared ${itemToMessage(deserializePartySource(command.item))} with the party`
         break
-      case 'seek-to':
+      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}`
         mayCombine = true
         break
-      case 'set-nickname':
+      case 'set nickname':
         actionmsg = `updated their nickname (from ${nickToMessage(command.oldNickname)})`
         senderNickname = command.nickname
         break
-      case 'set-socket-id':
+      case 'set socket id':
         return
-      case 'set-pause':
+      case 'set pause':
         if (command.paused) {
           actionmsg = 'paused the player'
         } else {
           actionmsg = 'resumed the player'
         }
         break
-      case 'stop-playing':
+      case 'stop playing':
         actionmsg = 'stopped the player'
         break
       case 'unqueue':
         actionmsg = `removed ${itemToMessage(command.topItem)} from the queue`
         break
+      case 'added queue player':
+        actionmsg = `created a new playback queue`
+        break
       case 'status':
         isVerbose = true
         switch (command.status) {
-          case 'ready-to-resume':
+          case 'ready to resume':
             actionmsg = `is ready to play!`
             break
-          case 'done-playing':
+          case 'done playing':
             actionmsg = `has finished playing`
             break
-          case 'sync-playback':
+          case 'sync playback':
             actionmsg = `synced playback with the server`
             break
           default:
@@ -635,7 +682,7 @@ function attachBackendToSocketClient(backend, client) {
     })
   }
 
-  client.on('sent-command', command => {
+  client.on('sent command', command => {
     command.senderNickname = client.nickname
     logCommand(command)
   })
@@ -645,18 +692,40 @@ function attachBackendToSocketClient(backend, client) {
     switch (command.sender) {
       case 'server':
         switch (command.code) {
-          case 'set-socket-id':
+          case 'set socket id':
             client.socketId = command.socketId
-            partyGrouplikeMap[command.socketId] = partyGrouplike
-            backend.loadPartyGrouplike(client.socketId, partyGrouplike)
+            socketInfoMap[command.socketId] = {
+              nickname: client.nickname,
+              sharedSources
+            }
+            backend.loadSharedSources(command.socketId, sharedSources)
             return
-          case 'initialize-backend':
+          case 'initialize party':
+            for (const [ socketId, info ] of Object.entries(command.socketInfo)) {
+              const nickname = info.nickname
+
+              const sharedSources = {
+                name: namePartySources(nickname),
+                isPartySources: true
+              }
+
+              sharedSources.items = info.sharedSources.map(
+                item => deserializePartySource(item, sharedSources))
+
+              socketInfoMap[socketId] = {
+                nickname,
+                sharedSources
+              }
+
+              backend.loadSharedSources(socketId, sharedSources)
+            }
             await restoreBackend(backend, command.backend)
-            backend.on('playing', QP => {
-              QP.once('received time data', () => {
-                client.sendCommand({code: 'status', status: 'sync-playback'})
-              })
-            })
+            attachPlaybackBackendListeners()
+            // backend.on('QP: playing', QP => {
+            //   QP.once('received time data', () => {
+            //     client.sendCommand({code: 'status', status: 'sync playback'})
+            //   })
+            // })
             return
         }
         // Again, no break. Client commands can come from the server.
@@ -667,31 +736,34 @@ function attachBackendToSocketClient(backend, client) {
         )
 
         switch (command.code) {
-          case 'announce-join': {
-            const partyGrouplike = {
-              name: `Party Sources - ${command.senderNickname}`,
+          case 'announce join': {
+            const sharedSources = {
+              name: namePartySources(command.senderNickname),
               isPartySources: true,
               items: []
             }
-            partyGrouplikeMap[command.senderSocketId] = partyGrouplike
-            backend.loadPartyGrouplike(command.senderSocketId, partyGrouplike)
+            socketInfoMap[command.senderSocketId] = {
+              nickname: command.senderNickname,
+              sharedSources
+            }
+            backend.loadSharedSources(command.senderSocketId, sharedSources)
             return
           }
-          case 'clear-queue':
-            if (QP) silenceEvents(QP, ['clear-queue'], () => QP.clearQueue())
+          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(
+          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(
+          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(
+          case 'distribute queue':
+            if (QP) silenceEvents(QP, ['distribute queue'], () => QP.distributeQueue(
               restoreNewItem(command.topItem),
               {
                 how: command.opts.how,
@@ -704,13 +776,13 @@ function attachBackendToSocketClient(backend, client) {
               QP.once('received time data', data => {
                 client.sendCommand({
                   code: 'status',
-                  status: 'ready-to-resume',
+                  status: 'ready to resume',
                   queuePlayer: QP.id
                 })
               })
-              silenceEvents(QP, ['playing'], () => QP.play(
-                restoreNewItem(command.track, getPlaylistSources())
-              ))
+              silenceEvents(QP, ['playing'], () => {
+                QP.play(restoreNewItem(command.track, getPlaylistSources()))
+              })
             }
             return
           case 'queue':
@@ -722,25 +794,26 @@ function attachBackendToSocketClient(backend, client) {
               }
             ))
             return
-          case 'restore-queue':
+          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))
+          case 'seek to':
+            if (QP) silenceEvents(QP, ['seek to'], () => QP.seekTo(command.time))
             return
-          case 'set-nickname': {
-            const partyGrouplike = partyGrouplikeMap[command.senderSocketId]
-            if (partyGrouplike) {
-              partyGrouplike.name = `Party Sources - ${command.senderNickname}`
-              backend.partyGrouplikeUpdated(client.socketId, partyGrouplike)
-            }
+          case 'set nickname': {
+            const info = socketInfoMap[command.senderSocketId]
+            info.nickname = command.senderNickname
+            info.sharedSources.name = namePartySources(command.senderNickname)
+            backend.sharedSourcesUpdated(client.socketId, info.sharedSources)
             return
           }
-          case 'set-pause': {
+          case 'set pause': {
+            // All this code looks very scary???
+            /*
             // TODO: there's an event leak here when toggling pause while
             // nothing is playing
             let playingThisTrack = true
@@ -749,20 +822,28 @@ function attachBackendToSocketClient(backend, client) {
             })
             setTimeout(() => {
               if (playingThisTrack) {
-                if (QP) silenceEvents(QP, ['set-pause'], () => QP.setPause(command.paused))
+                if (QP) silenceEvents(QP, ['set pause'], () => QP.setPause(command.paused))
               }
             }, command.startingTrack ? 500 : 0)
+            */
+            silenceEvents(QP, ['set pause'], () => QP.setPause(command.paused))
             return
           }
-          case 'share-with-party': {
-            const deserialized = deserializePartySource(command.item)
-            const partyGrouplike = partyGrouplikeMap[command.senderSocketId]
-            deserialized[parentSymbol] = partyGrouplike
-            partyGrouplike.items.push(deserialized)
-            backend.partyGrouplikeUpdated(command.senderSocketId, partyGrouplike)
+          case 'added queue player': {
+            silenceEvents(backend, ['added queue player'], () => {
+              const QP = backend.addQueuePlayer()
+              QP.id = command.id
+            })
+            return
+          }
+          case 'share with party': {
+            const { sharedSources } = socketInfoMap[command.senderSocketId]
+            const deserialized = deserializePartySource(command.item, sharedSources)
+            sharedSources.items.push(deserialized)
+            backend.sharedSourcesUpdated(command.senderSocketId, sharedSources)
             return
           }
-          case 'stop-playing':
+          case 'stop playing':
             if (QP) silenceEvents(QP, ['playing'], () => QP.stopPlaying())
             return
           case 'unqueue':
@@ -775,161 +856,162 @@ function attachBackendToSocketClient(backend, client) {
     }
   })
 
-  backend.on('clear-queue', queuePlayer => {
+  backend.on('announce join party', () => {
     client.sendCommand({
-      code: 'clear-queue',
-      queuePlayer: queuePlayer.id
+      code: 'announce join'
     })
   })
 
-  backend.on('clear-queue-past', (queuePlayer, track) => {
-    client.sendCommand({
-      code: 'clear-queue-past',
-      queuePlayer: queuePlayer.id,
-      track: saveItemReference(track)
-    })
+  backend.on('share with party', item => {
+    if (sharedSources.items.every(x => x[originalSymbol] !== item)) {
+      const serialized = serializePartySource(item)
+      const deserialized = deserializePartySource(serialized)
+
+      deserialized[parentSymbol] = sharedSources
+      deserialized[originalSymbol] = item
+
+      sharedSources.items.push(deserialized)
+      backend.sharedSourcesUpdated(client.socketId, sharedSources)
+
+      updateRestoredTracksUsingPlaylists(backend, getPlaylistSources())
+
+      client.sendCommand({
+        code: 'share with party',
+        item: serialized
+      })
+    }
   })
 
-  backend.on('clear-queue-up-to', (queuePlayer, track) => {
-    client.sendCommand({
-      code: 'clear-queue-up-to',
-      queuePlayer: queuePlayer.id,
-      track: saveItemReference(track)
-    })
+  backend.on('set party nickname', nickname => {
+    let oldNickname = client.nickname
+    sharedSources.name = namePartySources(nickname)
+    client.nickname = nickname
+    client.sendCommand({code: 'set nickname', nickname, oldNickname})
   })
 
-  backend.on('distribute-queue', (queuePlayer, topItem, opts) => {
-    client.sendCommand({
-      code: 'distribute-queue',
-      queuePlayer: queuePlayer.id,
-      topItem: saveItemReference(topItem),
-      opts
+  function attachPlaybackBackendListeners() {
+    backend.on('QP: clear queue', queuePlayer => {
+      client.sendCommand({
+        code: 'clear queue',
+        queuePlayer: queuePlayer.id
+      })
     })
-  })
 
-  backend.on('done playing', queuePlayer => {
-    client.sendCommand({
-      code: 'status',
-      status: 'done-playing',
-      queuePlayer: queuePlayer.id
+    backend.on('QP: clear queue past', (queuePlayer, track) => {
+      client.sendCommand({
+        code: 'clear queue past',
+        queuePlayer: queuePlayer.id,
+        track: saveItemReference(track)
+      })
     })
-  })
 
-  backend.on('playing', (queuePlayer, track) => {
-    if (track) {
+    backend.on('QP: clear queue up to', (queuePlayer, track) => {
       client.sendCommand({
-        code: 'play',
+        code: 'clear queue up to',
         queuePlayer: queuePlayer.id,
         track: saveItemReference(track)
       })
-      queuePlayer.once('received time data', data => {
-        client.sendCommand({
-          code: 'status',
-          status: 'ready-to-resume',
-          queuePlayer: queuePlayer.id
-        })
+    })
+
+    backend.on('QP: distribute queue', (queuePlayer, topItem, opts) => {
+      client.sendCommand({
+        code: 'distribute queue',
+        queuePlayer: queuePlayer.id,
+        topItem: saveItemReference(topItem),
+        opts
       })
-    } else {
+    })
+
+    backend.on('QP: done playing', queuePlayer => {
       client.sendCommand({
-        code: 'stop-playing',
+        code: 'status',
+        status: 'done 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('QP: 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('seek-ahead', handleSeek)
-  backend.on('seek-back', handleSeek)
-  backend.on('seek-to', handleSeek)
+    backend.on('QP: queue', (queuePlayer, topItem, afterItem, opts) => {
+      client.sendCommand({
+        code: 'queue',
+        queuePlayer: queuePlayer.id,
+        topItem: saveItemReference(topItem),
+        afterItem: saveItemReference(afterItem),
+        opts
+      })
+    })
 
-  backend.on('set party nickname', nickname => {
-    let oldNickname = client.nickname
-    partyGrouplike.name = `Party Sources - ${nickname}`
-    client.nickname = nickname
-    client.sendCommand({code: 'set-nickname', nickname, oldNickname})
-  })
+    function handleSeek(queuePlayer) {
+      client.sendCommand({
+        code: 'seek to',
+        queuePlayer: queuePlayer.id,
+        time: queuePlayer.time
+      })
+    }
 
-  backend.on('shuffle-queue', queuePlayer => {
-    client.sendCommand({
-      code: 'restore-queue',
-      why: 'shuffle',
-      queuePlayer: queuePlayer.id,
-      tracks: queuePlayer.queueGrouplike.items.map(saveItemReference)
-    })
-  })
+    backend.on('QP: seek ahead', handleSeek)
+    backend.on('QP: seek back', handleSeek)
+    backend.on('QP: seek to', handleSeek)
 
-  backend.on('toggle-pause', queuePlayer => {
-    client.sendCommand({
-      code: 'set-pause',
-      queuePlayer: queuePlayer.id,
-      paused: queuePlayer.player.isPaused
+    backend.on('QP: shuffle queue', queuePlayer => {
+      client.sendCommand({
+        code: 'restore queue',
+        why: 'shuffle',
+        queuePlayer: queuePlayer.id,
+        tracks: queuePlayer.queueGrouplike.items.map(saveItemReference)
+      })
     })
-  })
 
-  backend.on('unqueue', (queuePlayer, topItem) => {
-    client.sendCommand({
-      code: 'unqueue',
-      queuePlayer: queuePlayer.id,
-      topItem: saveItemReference(topItem)
+    backend.on('QP: toggle pause', queuePlayer => {
+      client.sendCommand({
+        code: 'set pause',
+        queuePlayer: queuePlayer.id,
+        paused: queuePlayer.player.isPaused
+      })
     })
-  })
 
-  backend.on('announce join party', () => {
-    client.sendCommand({
-      code: 'announce-join'
+    backend.on('QP: unqueue', (queuePlayer, topItem) => {
+      client.sendCommand({
+        code: 'unqueue',
+        queuePlayer: queuePlayer.id,
+        topItem: saveItemReference(topItem)
+      })
     })
-  })
-
-  backend.on('share with party', item => {
-    if (partyGrouplike.items.every(x => x[originalSymbol] !== item)) {
-      const serialized = serializePartySource(item)
-      const deserialized = deserializePartySource(serialized)
-
-      deserialized[parentSymbol] = partyGrouplike
-      deserialized[originalSymbol] = item
-
-      partyGrouplike.items.push(deserialized)
-      backend.partyGrouplikeUpdated(client.socketId, partyGrouplike)
-
-      updateRestoredTracksUsingPlaylists(backend, getPlaylistSources())
 
+    backend.on('added queue player', (queuePlayer) => {
       client.sendCommand({
-        code: 'share-with-party',
-        item: serialized
+        code: 'added queue player',
+        id: queuePlayer.id,
       })
-    }
-  })
+    })
+  }
 }
 
-function attachSocketServerToBackend(server, backend) {
+export 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, {
-  originalSymbol,
-  makeSocketServer,
-  makeSocketClient,
-  attachBackendToSocketClient,
-  attachSocketServerToBackend
-})
diff --git a/telnet.js b/telnet.js
index 33e3dcc..42e664d 100644
--- a/telnet.js
+++ b/telnet.js
@@ -1,16 +1,11 @@
-'use strict'
+import EventEmitter from 'node:events'
+import net from 'node:net'
 
-const EventEmitter = require('events')
-const net = require('net')
-const setupClient = require('./client')
+import {TelnetInterface} from 'tui-lib/util/interfaces'
 
-const {
-  util: {
-    TelnetInterfacer
-  }
-} = require('tui-lib')
+import setupClient from './client.js'
 
-class TelnetServer extends EventEmitter {
+export default class TelnetServer extends EventEmitter {
   constructor(backend) {
     super()
 
@@ -24,11 +19,11 @@ class TelnetServer extends EventEmitter {
   }
 
   async handleConnection(socket) {
-    const interfacer = new TelnetInterfacer(socket)
+    const telnetInterface = new TelnetInterface(socket)
     const { appElement, cleanTerminal, flushable } = await setupClient({
       backend: this.backend,
       writable: socket,
-      interfacer,
+      screenInterface: telnetInterface,
       appConfig: {
         canControlPlayback: false,
         canControlQueue: true,
@@ -47,7 +42,7 @@ class TelnetServer extends EventEmitter {
 
     const quit = (msg = 'See you!') => {
       cleanTerminal()
-      interfacer.cleanTelnetOptions()
+      telnetInterface.cleanTelnetOptions()
       socket.write('\r' + msg + '\r\n')
       socket.end()
       flushable.end()
@@ -77,5 +72,3 @@ class TelnetServer extends EventEmitter {
     }
   }
 }
-
-module.exports = TelnetServer
diff --git a/todo.txt b/todo.txt
index 726e04f..63ba2e2 100644
--- a/todo.txt
+++ b/todo.txt
@@ -577,9 +577,6 @@ TODO: "BAM #45.3 - no" displays as "BAM #45.no" in the queue? Seems wrong!
 TODO: "Challenge 1 (Tricks)" etc in FP World 3 are "Challenge (Tricks)"! Bad.
       (Done!)
 
-TODO: Pressing next track (shift+N) on the last track should start the first
-      track, if the queue is being looped.
-
 TODO: Tabber tab list should be accessible via tab (key).
 
 TODO: Show current index and number of tabs beside tabber tab list.
@@ -592,6 +589,7 @@ TODO: The checks for "grouplike"/"track" have been super arbitrary for a long
 
 TODO: Synchronize items that have been shared with the party upon a new client
       joining. Should be next to (or part of) the initialize-backend command.
+      (Done!)
 
 TODO: We currently use a hack to access the original item in the context menu
       for items in the party sources listing. This doesn't make, for example,
@@ -599,3 +597,171 @@ TODO: We currently use a hack to access the original item in the context menu
       to specifically refer to the item "represented" by a line, rather than
       the literal object it's associated with (i.e. the pseudo-track/group
       shared in the sources array).
+
+TODO: Broadcast when a socket disconnects; show a log message and remove their
+      shared sources from the UI of other clients.
+
+TODO: Ditto for the server! Not (exclusively) as a broadcast message, though -
+      detect if the connection to the server is lost for any reason.
+
+TODO: The validation code for share-with-party sucks! It should be made into a
+      separate function which runs recursively, and should be used to validate
+      initialize-party too.
+
+TODO: Show debug log messages when validating a command fails! On both server
+      and client end.
+
+TODO: Naming a shared sources list should definitely happen in a function.
+      (Done!)
+
+TODO: Pressing next track (N) on the last track should start the first track,
+      if the queue is being looped.
+      (Done!)
+
+TODO: Timestamp files. Oh heck yes.
+      (Done!)
+
+TODO: Show the current chunk of a track you're on according to its timestamps,
+      in both the queue and the main listing! (Put the playing indicator next
+      to both the track itself and the timestamp element.)
+
+      Possibly tricky, but try to make this tie in with the "time since/until"
+      indicator thingies at the bottom of the queue listing element too!
+      (Done - both parts!)
+
+TODO: Some kind of timestamp indicator in the progress bar area??? E.g, name
+      of the current timestamp, and MAYBE some kind of visual breakup of the
+      progress bar itself?
+
+TODO: Timestamp editing within mtui itself?????????
+
+TODO: Automatically expand/collapse timestamp lists in the queue sidebar!
+      (Done!)
+
+TODO: Apparently, seeking to a timestamp under a previous track in the queue
+      doesn't respect the current queue order (i.e. it sticks the track after
+      the current track). Definitely a bug!
+      (Done - fixed!)
+
+TODO: Next/previous buttons should seek between timestamps if there are more
+      within the same track.
+      (Done!)
+
+TODO: Should skipping back to a previous track with timestamps automatically
+      seek to the final timestamp within that track? I'm undecided, but at the
+      moment leaning *slightly* towards "no". I may be biased due to it is
+      harder to code that behavior though! :P
+
+TODO: The timestamp comment regex should probably skip dashes and other common
+      punctuation between the timestamp itself and the comment!
+
+TODO: Pressing ^L to locate the currently playing track (in the queue listing)
+      should focus the current timestamp, if there is one.
+
+TODO: I don't think "jump to" (/) works with timestamp items, lol.
+
+TODO: "Alphabetize order of groups" order option. Listen to the releases of
+      an artist, or your whole library, alphabetically - or prefix group names
+      with the date of release and play works chronologically! Or do whatever
+      other shenanigansy inline metadata you like.
+      (Done!)
+
+TODO: "Reveal in queue" option in the context menu for tracks that are part of
+      the queue! Also, rename existing "Reveal" option to "Reveal in library".
+      (Done!)
+
+TODO: Timestamps which have a timestampEnd property (all of them I think?)
+      should display their duration in the right column.
+
+TODO: Read timestamps as JSON when the file extension is .json. (Right now
+      any .timestamps.json file is ignored!)
+
+TODO: "Remove from queue" seems to always restore the cursor to a non-timestamp
+      input. This might be an issue with other queue-modifying actions too!
+
+TODO: The "From: <downloaderArg>" text in the playback info element *does* cut
+      off its text in an attempt to not go outside the screen bounds... but it
+      goes over the info pane edges anyway, so there's probably a math issue
+      there.
+
+TODO: "Play later" has a slight chance of keeping the track in the same place,
+      which is accentuated when there's only a couple tracks left in the queue.
+
+TODO: "Loop mode" should be an option under the Queue menu, not Playback.
+      (Done!)
+
+TODO: "Loop mode" setting should be displayed in the queue's length label!
+      Probably on the same line as ex. "2 / 3", and only when the currently
+      playing track is selected.
+      (Done!)
+
+TODO: "Clear past current" and "clear up to current" should probably be visible
+      from the Queue menu!
+
+TODO: The queue length lebel is kinda busy, and doesn't fit everything so well
+      on thinner screens. That should get checked out!
+      (Done!)
+
+TODO: When the last track in the queue finishes playing and the queue is set to
+      shuffle, the currently selected index in the queue listing won't be moved
+      to the new first track (so, reset to zero). The cursor just ends up on
+      whatever track had been the last in the queue (which is obviously now in
+      some random location - even possibly the first track, but usually not).
+      I have a feeling this is the result of shuffling first - which updates
+      the selected index to go to wherever the last track ended up - and then
+      playing the first track, but not moving the cursor back to the start
+      because it's apparently not at the end anymore. But I could be totally
+      misremembering how this code works. :P --- Nope not even related LOL.
+      Good guess though! We don't even have to worry about that situation, with
+      the way selecting the new playing track works. It checks against the
+      track which *was* playing... but that was getting cleared to make the
+      shuffle work properly (applying to the whole queue instead of just the
+      stuff past the current track, which is nothing when you're at its end).
+      Now we just use a flag to ignore the current playback position. Since the
+      currently playing track is retained for the 'playing track' event, the
+      existing code does the rest of the work and selects the newly playing
+      track (whatever's been shuffled to the start) all on its own!
+      (Done!)
+
+TODO: Apparently pressing any key while the UI is booting up will make the
+      screen totally black and unresponsive (and apparently inactive) until the
+      screen is resized. I think we're interrupting a control sequence somehow,
+      and that isn't being handled very well?
+
+TODO: Pressing escape while you've got items selected should deselect those
+      items, rather than stop playback! ...Or SHOULD IT??? Well, yes. But it's
+      still handy to not be locked out of stopping playback altogether.
+      Alternative: clear the selection (without stopping playback) only if the
+      cursor is currently on a selected item.
+
+TODO: GHOST BACKEND for socket server... the main thing is syncing duration
+      data. It sucks to have the player, like, actually be tied to a specific
+      instance of MPV or whatever, so we'd use a ~ghost player~ which supports
+      all the usual interfaces and lies about its current playback time. Yay!
+      (Partway: The ghost player exists now, and the backend and UI handle it!
+       Just need to hook up a "dummy" backend for the server, with ghost player
+       and duration metadata received from socket clients.)
+
+TODO: There should be a way for the server to handle disputes between two
+      clients disagreeing on the duration of a track. Options could include,
+      for example, "longest": always wait for everyone to be done playing;
+      "shortest": don't wait for anyone to be done (past a 1 second threshold
+      or whatever), just skip to the next track almost right away; and "first",
+      where duration just depends on whoever shared the track. This can all be
+      done without everyone sharing their own playback duration, which is kinda
+      wasteful; it would be controlled totally by the server deciding when to
+      send out events to start the next track, and in reaction only to the
+      clients' own "done playing" events (or the GHOST PLAYER reaching the
+      playback time provided when the track was first shared).
+
+TODO: Implement a waaaay better socat system, particularly one which waits for
+      feedback when a command is sent and returns that. This has to be special-
+      coded for mpv since there isn't a generalized standard, so it should make
+      use of the existing Socat class, not replace it outright.
+
+TODO: Use above socat system to keep "pinging" the socket until a response is
+      received - mpv doesn't make the socket immediately available. I think if
+      we wait for a pong response before allowing any actual commands to go
+      through, we can avoid weirdness with commands being dropped beacuse they
+      were sent too early. For now we just use a time-based delay on the base
+      Socat class, which is a hack.
diff --git a/ui.js b/ui.js
index bd73bdc..6cabb32 100644
--- a/ui.js
+++ b/ui.js
@@ -1,24 +1,45 @@
 // The UI in MTUI! Interfaces with the backend to form the complete mtui app.
 
-'use strict'
+import {spawn} from 'node:child_process'
+import {readFile, writeFile} from 'node:fs/promises'
+import path from 'node:path'
+import url from 'node:url'
 
-const { getAllCrawlersForArg } = require('./crawlers')
-const processSmartPlaylist = require('./smart-playlist')
-const UndoManager = require('./undo-manager')
+import {orderBy} from 'natural-orderby'
+import open from 'open'
 
-const {
+import {Button, Form, ListScrollForm, TextInput} from 'tui-lib/ui/controls'
+import {Dialog} from 'tui-lib/ui/dialogs'
+import {Label, Pane, WrapLabel} from 'tui-lib/ui/presentation'
+import {DisplayElement, FocusElement} from 'tui-lib/ui/primitives'
+
+import * as ansi from 'tui-lib/util/ansi'
+import telc from 'tui-lib/util/telchars'
+import unic from 'tui-lib/util/unichars'
+
+import {getAllCrawlersForArg} from './crawlers.js'
+import {originalSymbol} from './socket.js'
+import processSmartPlaylist from './smart-playlist.js'
+import UndoManager from './undo-manager.js'
+
+import {
   commandExists,
+  getSecFromTimestamp,
   getTimeStringsFromSec,
   promisifyProcess,
-  shuffleArray
-} = require('./general-util')
+  shuffleArray,
+} from './general-util.js'
 
-const {
+import {
   cloneGrouplike,
+  collapseGrouplike,
   countTotalTracks,
+  findItemObject,
   flattenGrouplike,
   getCorrespondingFileForItem,
   getCorrespondingPlayableForFile,
+  getFlatTrackList,
+  getFlatGroupList,
   getItemPath,
   getNameWithoutTrackNumber,
   isGroup,
@@ -28,55 +49,19 @@ const {
   parentSymbol,
   reverseOrderOfGroups,
   searchForItem,
-  shuffleOrderOfGroups
-} = require('./playlist-utils')
+  shuffleOrderOfGroups,
+} from './playlist-utils.js'
 
-const {
+import {
   updateRestoredTracksUsingPlaylists,
   getWaitingTrackData
-} = require('./serialized-backend')
-
-const {
-  ui: {
-    Dialog,
-    DisplayElement,
-    Label,
-    Pane,
-    WrapLabel,
-    form: {
-      Button,
-      FocusElement,
-      Form,
-      ListScrollForm,
-      TextInput,
-    }
-  },
-  util: {
-    ansi,
-    telchars: telc,
-    unichars: unic,
-  }
-} = require('tui-lib')
-
-const {
-  originalSymbol
-} = require('./socket')
+} from './serialized-backend.js'
 
 /* text editor features disabled because theyre very much incomplete and havent
  * gotten much use from me or anyone afaik!
 const TuiTextEditor = require('tui-text-editor')
 */
 
-const { promisify } = require('util')
-const { spawn } = require('child_process')
-const { orderBy } = require('natural-orderby')
-const fs = require('fs')
-const open = require('open')
-const path = require('path')
-const url = require('url')
-const readFile = promisify(fs.readFile)
-const writeFile = promisify(fs.writeFile)
-
 const input = {}
 
 const keyBindings = [
@@ -116,6 +101,8 @@ const keyBindings = [
   // ['isFocusLabels', 'L', {caseless: false}], // todo: better key? to let isToggleLoop be caseless again
   ['isSelectUp', telc.isShiftUp],
   ['isSelectDown', telc.isShiftDown],
+  ['isNextThemeColor', 'c', {caseless: false}],
+  ['isPreviousThemeColor', 'C', {caseless: false}],
 
   ['isPreviousPlayer', telc.isMetaUp],
   ['isPreviousPlayer', [0x1b, 'p']],
@@ -191,7 +178,7 @@ telc.isRight = input.isRight
 telc.isSelect = input.isSelect
 telc.isBackspace = input.isBackspace
 
-class AppElement extends FocusElement {
+export default class AppElement extends FocusElement {
   constructor(backend, config = {}) {
     super()
 
@@ -208,7 +195,8 @@ class AppElement extends FocusElement {
       canControlQueuePlayers: true,
       canProcessMetadata: true,
       canSuspend: true,
-      menubarColor: 4, // blue
+      themeColor: 4, // blue
+      seekToStartThreshold: 3,
       showTabberPane: true,
       stopPlayingUponQuit: true,
       showPartyControls: false
@@ -220,6 +208,8 @@ class AppElement extends FocusElement {
     this.cachedMarkStatuses = new Map()
     this.editMode = false
 
+    this.timestampDictionary = new WeakMap()
+
     // We add this is a child later (so that it's on top of every element).
     this.menuLayer = new DisplayElement()
     this.menuLayer.clickThrough = true
@@ -228,7 +218,8 @@ class AppElement extends FocusElement {
     this.menubar = new Menubar(this.showContextMenu)
     this.addChild(this.menubar)
 
-    this.menubar.color = this.config.menubarColor
+    this.setThemeColor(this.config.themeColor)
+    this.menubar.on('color', color => this.setThemeColor(color))
 
     this.tabberPane = new Pane()
     this.addChild(this.tabberPane)
@@ -257,7 +248,7 @@ class AppElement extends FocusElement {
     this.logPane.addChild(this.log)
     this.logPane.visible = false
 
-    this.log.on('log-message', () => {
+    this.log.on('log message', () => {
       this.logPane.visible = true
       this.fixLayout()
     })
@@ -273,8 +264,6 @@ class AppElement extends FocusElement {
     this.metadataStatusLabel.visible = false
     this.tabberPane.addChild(this.metadataStatusLabel)
 
-    this.newGrouplikeListing()
-
     this.queueListingElement = new QueueListingElement(this)
     this.setupCommonGrouplikeListingEvents(this.queueListingElement)
     this.queuePane.addChild(this.queueListingElement)
@@ -285,7 +274,7 @@ class AppElement extends FocusElement {
     this.queueTimeLabel = new Label('')
     this.queuePane.addChild(this.queueTimeLabel)
 
-    this.queueListingElement.on('select', item => this.updateQueueLengthLabel())
+    this.queueListingElement.on('select', _item => this.updateQueueLengthLabel())
     this.queueListingElement.on('open', item => this.openSpecialOrThroughSystem(item))
     this.queueListingElement.on('queue', item => this.play(item))
     this.queueListingElement.on('remove', item => this.unqueue(item))
@@ -294,6 +283,11 @@ class AppElement extends FocusElement {
     this.queueListingElement.on('select main listing',
       () => this.selected())
 
+    if (this.config.showPartyControls) {
+      const sharedSourcesListing = this.newGrouplikeListing()
+      sharedSourcesListing.loadGrouplike(this.backend.sharedSourcesGrouplike)
+    }
+
     this.playbackPane = new Pane()
     this.addChild(this.playbackPane)
 
@@ -346,6 +340,7 @@ class AppElement extends FocusElement {
       {value: 'reverse', label: 'Reverse all'},
       {value: 'reverse-groups', label: 'Reverse order of groups'},
       {value: 'alphabetic', label: 'Alphabetically'},
+      {value: 'alphabetic-groups', label: 'Alphabetize order of groups'},
       {value: 'normal', label: 'In order'}
     ], this.showContextMenu)
 
@@ -366,15 +361,17 @@ class AppElement extends FocusElement {
         return [
           {label: playingTrack ? `("${playingTrack.name}")` : '(No track playing.)'},
           {divider: true},
+          {element: this.volumeSlider},
+          {divider: true},
           playingTrack && {element: this.playingControl},
-          {element: this.loopingControl},
-          {element: this.loopQueueControl},
-          {element: this.pauseNextControl},
+          playingTrack && {element: this.loopingControl},
+          playingTrack && {element: this.pauseNextControl},
           {element: this.autoDJControl},
-          {element: this.volumeSlider},
           {divider: true},
           previous && {label: `Previous (${previous.name})`, action: () => this.SQP.playPrevious(playingTrack)},
           next && {label: `Next (${next.name})`, action: () => this.SQP.playNext(playingTrack)},
+          !next && this.SQP.queueEndMode === 'loop' &&
+          {label: `Next (loop queue)`, action: () => this.SQP.playNext(playingTrack)},
           next && {label: '- Play later', action: () => this.playLater(next)}
         ]
       }},
@@ -385,6 +382,8 @@ class AppElement extends FocusElement {
         return [
           {label: `(Queue - ${curIndex >= 0 ? `${curIndex + 1}/` : ''}${items.length} items.)`},
           {divider: true},
+          {element: this.loopModeControl},
+          {divider: true},
           items.length && {label: 'Shuffle', action: () => this.shuffleQueue()},
           items.length && {label: 'Clear', action: () => this.clearQueue()}
         ]
@@ -418,6 +417,20 @@ class AppElement extends FocusElement {
       getEnabled: () => this.config.canControlPlayback
     })
 
+    this.loopModeControl = new InlineListPickerElement('Loop queue?', [
+      {value: 'end', label: 'Don\'t loop'},
+      {value: 'loop', label: 'Loop (same order)'},
+      {value: 'shuffle', label: 'Loop (shuffle)'}
+    ], {
+      setValue: val => {
+        if (this.SQP) {
+          this.SQP.queueEndMode = val
+        }
+      },
+      getValue: () => this.SQP && this.SQP.queueEndMode,
+      showContextMenu: this.showContextMenu
+    })
+
     this.pauseNextControl = new ToggleControl('Pause when this track ends?', {
       setValue: val => this.SQP.setPauseNextTrack(val),
       getValue: () => this.SQP.pauseNextTrack,
@@ -438,7 +451,7 @@ class AppElement extends FocusElement {
 
     this.autoDJControl = new ToggleControl('Enable Auto-DJ?', {
       setValue: val => (this.enableAutoDJ = val),
-      getValue: val => this.enableAutoDJ,
+      getValue: () => this.enableAutoDJ,
       getEnabled: () => this.config.canControlPlayback
     })
 
@@ -461,8 +474,8 @@ class AppElement extends FocusElement {
       'handleAddedQueuePlayer',
       'handleRemovedQueuePlayer',
       'handleLogMessage',
-      'handleGotPartyGrouplike',
-      'handlePartyGrouplikeUpdated',
+      'handleGotSharedSources',
+      'handleSharedSourcesUpdated',
       'handleSetLoopQueueAtEnd'
     ]) {
       this[key] = this[key].bind(this)
@@ -494,10 +507,6 @@ class AppElement extends FocusElement {
     PIE.on('seek back', () => PIE.queuePlayer.seekBack(5))
     PIE.on('seek ahead', () => PIE.queuePlayer.seekAhead(5))
     PIE.on('toggle pause', () => PIE.queuePlayer.togglePause())
-
-    queuePlayer.on('received time data', this.handleReceivedTimeData)
-    queuePlayer.on('playing details', this.handlePlayingDetails)
-    queuePlayer.on('queue updated', this.handleQueueUpdated)
   }
 
   removeQueuePlayerListenersAndUI(queuePlayer, updateUI = true) {
@@ -524,30 +533,39 @@ class AppElement extends FocusElement {
       this.queuePlayersToActOn.splice(index, 1)
     }
 
-    queuePlayer.removeListener('receivedTimeData', this.handleReceivedTimeData)
-    queuePlayer.removeListener('playing details', this.handlePlayingDetails)
-    queuePlayer.removeListener('queue updated', this.handleQueueUpdated)
     queuePlayer.stopPlaying()
   }
 
   attachBackendListeners() {
+    // Backend-specialized listeners
     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)
-    this.backend.on('got party grouplike', this.handleGotPartyGrouplike)
-    this.backend.on('party grouplike updated', this.handlePartyGrouplikeUpdated)
-    this.backend.on('set-loop-queue-at-end', this.handleSetLoopQueueAtEnd)
+    this.backend.on('got shared sources', this.handleGotSharedSources)
+    this.backend.on('shared sources updated', this.handleSharedSourcesUpdated)
+    this.backend.on('set loop queue at end', this.handleSetLoopQueueAtEnd)
+
+    // Backend as queue player proxy listeners
+    this.backend.on('QP: playing details', this.handlePlayingDetails)
+    this.backend.on('QP: received time data', this.handleReceivedTimeData)
+    this.backend.on('QP: queue updated', this.handleQueueUpdated)
   }
 
   removeBackendListeners() {
+    // Backend-specialized listeners
     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)
-    this.backend.removeListener('got party grouplike', this.handleGotPartyGrouplike)
-    this.backend.removeListener('party grouplike updated', this.handlePartyGrouplikeUpdated)
-    this.backend.removeListener('set-loop-queue-at-end', this.handleSetLoopQueueAtEnd)
+    this.backend.removeListener('got shared sources', this.handleGotSharedSources)
+    this.backend.removeListener('shared sources updated', this.handleSharedSourcesUpdated)
+    this.backend.removeListener('set loop queue at end', this.handleSetLoopQueueAtEnd)
+
+    // Backend as queue player proxy listeners
+    this.backend.removeListener('QP: playing details', this.handlePlayingDetails)
+    this.backend.removeListener('QP: received time data', this.handleReceivedTimeData)
+    this.backend.removeListener('QP: queue updated', this.handleQueueUpdated)
   }
 
   handleAddedQueuePlayer(queuePlayer) {
@@ -565,23 +583,28 @@ class AppElement extends FocusElement {
     this.log.newLogMessage(messageInfo)
   }
 
-  handleGotPartyGrouplike(socketId, partyGrouplike) {
-    this.newPartyTab(socketId, partyGrouplike)
+  handleGotSharedSources(socketId, sharedSources) {
+    for (const grouplikeListing of this.tabber.tabberElements) {
+      if (grouplikeListing.grouplike === this.backend.sharedSourcesGrouplike) {
+        grouplikeListing.loadGrouplike(this.backend.sharedSourcesGrouplike, false)
+      }
+    }
   }
 
-  handlePartyGrouplikeUpdated(socketId, partyGrouplike) {
+  handleSharedSourcesUpdated(socketId, partyGrouplike) {
     for (const grouplikeListing of this.tabber.tabberElements) {
       if (grouplikeListing.grouplike === partyGrouplike) {
         grouplikeListing.loadGrouplike(partyGrouplike, false)
       }
     }
+    this.clearCachedMarkStatuses()
   }
 
   handleSetLoopQueueAtEnd() {
     this.updateQueueLengthLabel()
   }
 
-  async handlePlayingDetails(track, oldTrack, queuePlayer) {
+  async handlePlayingDetails(queuePlayer, track, oldTrack, startTime) {
     const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
     if (PIE) {
       PIE.updateTrack()
@@ -589,11 +612,21 @@ class AppElement extends FocusElement {
 
     if (queuePlayer === this.SQP) {
       this.updateQueueLengthLabel()
+      this.queueListingElement.collapseTimestamps(oldTrack)
       if (track && this.queueListingElement.currentItem === oldTrack) {
         this.queueListingElement.selectAndShow(track)
       }
     }
 
+    // Unfortunately, there isn't really any reliable way to make these work if
+    // the containing queue isn't of the selected queue player.
+    const timestampData = track && this.getTimestampData(track)
+    if (timestampData && queuePlayer === this.SQP) {
+      if (this.queueListingElement.currentItem === track) {
+        this.queueListingElement.selectTimestampAtSec(track, startTime)
+      }
+    }
+
     if (track && this.enableAutoDJ) {
       queuePlayer.setVolumeMultiplier(0.5);
       const message = 'now playing: ' + getNameWithoutTrackNumber(track);
@@ -606,7 +639,7 @@ class AppElement extends FocusElement {
     }
   }
 
-  handleReceivedTimeData(data, queuePlayer) {
+  handleReceivedTimeData(queuePlayer, timeData, oldTimeData) {
     const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
     if (PIE) {
       PIE.updateProgress()
@@ -614,6 +647,7 @@ class AppElement extends FocusElement {
 
     if (queuePlayer === this.SQP) {
       this.updateQueueLengthLabel()
+      this.updateQueueSelection(timeData, oldTimeData)
     }
   }
 
@@ -707,7 +741,7 @@ class AppElement extends FocusElement {
     this.tabber.addTab(grouplikeListing)
     this.tabber.selectTab(grouplikeListing)
 
-    grouplikeListing.on('browse', item => grouplikeListing.loadGrouplike(item))
+    grouplikeListing.on('browse', item => this.browse(grouplikeListing, item))
     grouplikeListing.on('download', item => this.SQP.download(item))
     grouplikeListing.on('open', item => this.openSpecialOrThroughSystem(item))
     grouplikeListing.on('queue', (item, opts) => this.handleQueueOptions(item, opts))
@@ -715,7 +749,7 @@ class AppElement extends FocusElement {
     const updateListingsFor = item => {
       for (const grouplikeListing of this.tabber.tabberElements) {
         if (grouplikeListing.grouplike === item) {
-          grouplikeListing.loadGrouplike(item, false)
+          this.browse(grouplikeListing, item, false)
         }
       }
     }
@@ -803,12 +837,13 @@ class AppElement extends FocusElement {
     // Sets up event listeners that are common to ordinary grouplike listings
     // (made by newGrouplikeListing) as well as the queue grouplike listing.
 
-    grouplikeListing.pathElement.on('select', (item, child) => this.reveal(item, child))
+    grouplikeListing.on('timestamp', (item, time) => this.playOrSeek(item, time))
+    grouplikeListing.pathElement.on('select', (item, child) => this.revealInLibrary(item, child))
     grouplikeListing.on('menu', (item, el) => this.showMenuForItemElement(el, grouplikeListing))
     /*
     grouplikeListing.on('select', item => this.editNotesFile(item, false))
     grouplikeListing.on('edit-notes', item => {
-      this.reveal(item)
+      this.revealInLibrary(item)
       this.editNotesFile(item, true)
     })
     */
@@ -824,7 +859,12 @@ class AppElement extends FocusElement {
     return menu
   }
 
-  reveal(item, child) {
+  browse(grouplikeListing, grouplike, ...args) {
+    this.loadTimestampDataInGrouplike(grouplike)
+    grouplikeListing.loadGrouplike(grouplike, ...args)
+  }
+
+  revealInLibrary(item, child) {
     if (!this.tabberPane.visible) {
       return
     }
@@ -846,6 +886,13 @@ class AppElement extends FocusElement {
     }
   }
 
+  revealInQueue(item) {
+    const queueListing = this.queueListingElement
+    if (queueListing.selectAndShow(item)) {
+      this.root.select(queueListing)
+    }
+  }
+
   play(item) {
     if (!this.config.canControlQueue) {
       return
@@ -854,6 +901,14 @@ class AppElement extends FocusElement {
     this.SQP.play(item)
   }
 
+  playOrSeek(item, time) {
+    if (!this.config.canControlQueue || !this.config.canControlPlayback) {
+      return
+    }
+
+    this.SQP.playOrSeek(item, time)
+  }
+
   unqueue(item) {
     if (!this.config.canControlQueue) {
       return
@@ -979,7 +1034,11 @@ class AppElement extends FocusElement {
   }
 
   emitMarkChanged() {
+    this.clearCachedMarkStatuses()
     this.emit('mark changed')
+  }
+
+  clearCachedMarkStatuses() {
     this.cachedMarkStatuses = new Map()
     this.scheduleDrawWithoutPropertyChange()
   }
@@ -1082,6 +1141,138 @@ class AppElement extends FocusElement {
   }
   */
 
+  expandTimestamps(item, listing) {
+    listing.expandTimestamps(item)
+  }
+
+  collapseTimestamps(item, listing) {
+    listing.collapseTimestamps(item)
+  }
+
+  toggleTimestamps(item, listing) {
+    listing.toggleTimestamps(item)
+  }
+
+  timestampsExpanded(item, listing) {
+    return listing.timestampsExpanded(item)
+  }
+
+  hasTimestampsFile(item) {
+    return !!this.getTimestampsFile(item)
+  }
+
+  getTimestampsFile(item) {
+    // Only tracks have timestamp files!
+    if (!isTrack(item)) {
+      return false
+    }
+
+    return getCorrespondingFileForItem(item, '.timestamps.txt')
+  }
+
+  async loadTimestampDataInGrouplike(grouplike) {
+    // Only load data for a grouplike once.
+    if (this.timestampDictionary.has(grouplike)) {
+      return
+    }
+
+    this.timestampDictionary.set(grouplike, true)
+
+    // There's no parallelization here, but like, whateeeever.
+    for (const item of grouplike.items) {
+      if (!isTrack(item)) {
+        continue
+      }
+
+      if (this.timestampDictionary.has(item)) {
+        continue
+      }
+
+      if (!this.hasTimestampsFile(item)) {
+        this.timestampDictionary.set(item, false)
+        continue
+      }
+
+      this.timestampDictionary.set(item, null)
+      const data = await this.readTimestampData(item)
+      this.timestampDictionary.set(item, data)
+    }
+  }
+
+  getTimestampData(item) {
+    return this.timestampDictionary.get(item) || null
+  }
+
+  getTimestampAtSec(item, sec) {
+    const timestampData = this.getTimestampData(item)
+    if (!timestampData) {
+      return null
+    }
+
+    // Just like, start from the end, man.
+    // Why doesn't JavaScript have a findIndexFromEnd function???
+    for (let i = timestampData.length - 1; i >= 0; i--) {
+      const ts = timestampData[i];
+      if (
+        ts.timestamp <= sec &&
+        ts.timestampEnd >= sec
+      ) {
+        return ts
+      }
+    }
+
+    return null
+  }
+
+  async readTimestampData(item) {
+    const file = this.getTimestampsFile(item)
+
+    if (!file) {
+      return null
+    }
+
+    let filePath
+    try {
+      filePath = url.fileURLToPath(new URL(file.url))
+    } catch (error) {
+      return null
+    }
+
+    let contents
+    try {
+      contents = (await readFile(filePath)).toString()
+    } catch (error) {
+      return null
+    }
+
+    if (contents.startsWith('[')) {
+      try {
+        return JSON.parse(contents)
+      } catch (error) {
+        return null
+      }
+    }
+
+    const lines = contents.split('\n')
+      .filter(line => !line.startsWith('#'))
+      .filter(line => line)
+
+    const metadata = this.backend.getMetadataFor(item)
+    const duration = (metadata ? metadata.duration : Infinity)
+
+    const data = lines
+      .map(line => line.match(/^\s*([0-9:.]+)\s*(\S.*)\s*$/))
+      .filter(match => match)
+      .map(match => ({timestamp: getSecFromTimestamp(match[1]), comment: match[2]}))
+      .filter(({ timestamp: sec }) => !isNaN(sec))
+      .map((cur, i, arr) =>
+        (i + 1 === arr.length
+          ? {...cur, timestampEnd: duration}
+          : {...cur, timestampEnd: arr[i + 1].timestamp}))
+
+    return data
+  }
+
   openSpecialOrThroughSystem(item) {
     if (item.url.endsWith('.json')) {
       return this.loadPlaylistOrSource(item.url, true)
@@ -1126,21 +1317,119 @@ class AppElement extends FocusElement {
     }
   }
 
+  skipBackOrSeekToStart() {
+    // Perform the same action - skipping to the previous track or seeking to
+    // the start of the current track - for all target queue players. If any is
+    // past an arbitrary time position (default 3 seconds), seek to start; if
+    // all are before this position, skip to previous.
+
+    let maxCurSec = 0
+    this.forEachQueuePlayerToActOn(qp => {
+      if (qp.timeData) {
+        let effectiveCurSec = qp.timeData.curSecTotal
+
+        const ts = (qp.timeData &&
+          this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal))
+
+        if (ts) {
+          effectiveCurSec -= ts.timestamp
+        }
+
+        maxCurSec = Math.max(maxCurSec, effectiveCurSec)
+      }
+    })
+
+    if (Math.floor(maxCurSec) < this.config.seekToStartThreshold) {
+      this.skipBack()
+    } else {
+      this.seekToStart()
+    }
+  }
+
+  seekToStart() {
+    this.actOnQueuePlayers(qp => qp.seekToStart())
+    this.actOnQueuePlayers(qp => {
+      if (!qp.playingTrack) {
+        return
+      }
+
+      const ts = (qp.timeData &&
+        this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal))
+
+      if (ts) {
+        qp.seekTo(ts.timestamp)
+        return
+      }
+
+      qp.seekToStart()
+    })
+  }
+
+  skipBack() {
+    this.actOnQueuePlayers(qp => {
+      if (!qp.playingTrack) {
+        return
+      }
+
+      const ts = (qp.timeData &&
+        this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal))
+
+      if (ts) {
+        const timestampData = this.getTimestampData(qp.playingTrack)
+        const playingTimestampIndex = timestampData.indexOf(ts)
+        const previous = timestampData[playingTimestampIndex - 1]
+        if (previous) {
+          qp.seekTo(previous.timestamp)
+          return
+        }
+      }
+
+      qp.playPrevious(qp.playingTrack, true)
+    })
+  }
+
+  skipAhead() {
+    this.actOnQueuePlayers(qp => {
+      if (!qp.playingTrack) {
+        return
+      }
+
+      const ts = (qp.timeData &&
+        this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal))
+
+      if (ts) {
+        const timestampData = this.getTimestampData(qp.playingTrack)
+        const playingTimestampIndex = timestampData.indexOf(ts)
+        const next = timestampData[playingTimestampIndex + 1]
+        if (next) {
+          qp.seekTo(next.timestamp)
+          return
+        }
+      }
+
+      qp.playNext(qp.playingTrack, true)
+    })
+  }
+
   actOnQueuePlayers(fn) {
-    const actOn = this.queuePlayersToActOn.length ? this.queuePlayersToActOn : [this.SQP]
-    for (const queuePlayer of actOn) {
+    this.forEachQueuePlayerToActOn(queuePlayer => {
       fn(queuePlayer)
       const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
       if (PIE) {
         PIE.updateProgress()
       }
-    }
+    })
+  }
+
+  forEachQueuePlayerToActOn(fn) {
+    const actOn = this.queuePlayersToActOn.length ? this.queuePlayersToActOn : [this.SQP]
+    actOn.forEach(fn)
   }
 
   showMenuForItemElement(el, listing) {
-    const { editMode } = this
+    // const { editMode } = this
     const { canControlQueue, canProcessMetadata } = this.config
-    const anyMarked = editMode && this.markGrouplike.items.length > 0
+    // const anyMarked = editMode && this.markGrouplike.items.length > 0
 
     const generatePageForItem = item => {
       const emitControls = play => () => {
@@ -1151,22 +1440,43 @@ class AppElement extends FocusElement {
         })
       }
 
-      const rootGroup = getItemPath(item)[0]
+      const itemPath = getItemPath(item)
+      const [rootGroup, _partySources, sharedGroup] = itemPath
 
       // This is the hack mentioned in the todo!!!!
-      if (
-        this.config.showPartyControls &&
-        rootGroup.isPartySources &&
-        item[originalSymbol]
-      ) {
-        item = item[originalSymbol]
+      if (this.config.showPartyControls && rootGroup.isPartySources) {
+        const playlists = this.tabber.tabberElements
+          .map(grouplikeListing => getItemPath(grouplikeListing.grouplike)[0])
+          .filter(root => !root.isPartySources)
+
+        let possibleChoices = []
+        if (item.downloaderArg) {
+          possibleChoices = getFlatTrackList({items: playlists})
+        } else if (item.items) {
+          possibleChoices = getFlatGroupList({items: playlists})
+        }
+        if (possibleChoices) {
+          item = findItemObject(item, possibleChoices)
+        }
+
+        if (!item) {
+          return [
+            {label: `(Couldn't find this in your music)`}
+          ]
+        }
       }
 
-      const hasNotesFile = !!getCorrespondingFileForItem(item, '.txt')
+      // const hasNotesFile = !!getCorrespondingFileForItem(item, '.txt')
+      const timestampsItem = this.hasTimestampsFile(item) && (this.timestampsExpanded(item, listing)
+        ? {label: 'Collapse saved timestamps', action: () => this.collapseTimestamps(item, listing)}
+        : {label: 'Expand saved timestamps', action: () => this.expandTimestamps(item, listing)}
+      )
+      const isQueued = this.SQP.queueGrouplike.items.includes(item)
 
       if (listing.grouplike.isTheQueue && isTrack(item)) {
         return [
-          item[parentSymbol] && this.tabberPane.visible && {label: 'Reveal', action: () => this.reveal(item)},
+          item[parentSymbol] && this.tabberPane.visible && {label: 'Reveal in library', action: () => this.revealInLibrary(item)},
+          timestampsItem,
           {divider: true},
           canControlQueue && {label: 'Play later', action: () => this.playLater(item)},
           canControlQueue && {label: 'Play sooner', action: () => this.playSooner(item)},
@@ -1200,13 +1510,12 @@ class AppElement extends FocusElement {
           // to move the "mark"/"paste" (etc) code into separate functions,
           // instead of just defining their behavior inside the listing event
           // handlers.
-          /*
-          editMode && {label: isMarked ? 'Unmark' : 'Mark', action: () => el.emit('mark')},
-          anyMarked && {label: 'Paste (above)', action: () => el.emit('paste', {where: 'above'})},
-          anyMarked && {label: 'Paste (below)', action: () => el.emit('paste', {where: 'below'})},
+
+          // editMode && {label: isMarked ? 'Unmark' : 'Mark', action: () => el.emit('mark')},
+          // anyMarked && {label: 'Paste (above)', action: () => el.emit('paste', {where: 'above'})},
+          // anyMarked && {label: 'Paste (below)', action: () => el.emit('paste', {where: 'below'})},
           // anyMarked && !this.isReal && {label: 'Paste', action: () => this.emit('paste')}, // No "above" or "elow" in the label because the "fake" item/row will be replaced (it'll disappear, since there'll be an item in the group)
-          {divider: true},
-          */
+          // {divider: true},
 
           ...((this.config.showPartyControls && !rootGroup.isPartySources)
             ? [
@@ -1219,25 +1528,24 @@ class AppElement extends FocusElement {
               canControlQueue && isPlayable(item) && {label: 'Play!', action: emitControls(true)},
               canControlQueue && isPlayable(item) && {label: 'Queue!', action: emitControls(false)},
               {divider: true},
-
               canProcessMetadata && isGroup(item) && {label: 'Process metadata (new entries)', action: () => setTimeout(() => this.processMetadata(item, false))},
               canProcessMetadata && isGroup(item) && {label: 'Process metadata (reprocess)', action: () => setTimeout(() => this.processMetadata(item, true))},
               canProcessMetadata && isTrack(item) && {label: 'Process metadata', action: () => setTimeout(() => this.processMetadata(item, true))},
               isOpenable(item) && item.url.endsWith('.json') && {label: 'Open (JSON Playlist)', action: () => this.openSpecialOrThroughSystem(item)},
               isOpenable(item) && {label: 'Open (System)', action: () => this.openThroughSystem(item)},
-              /*
-              !hasNotesFile && isPlayable(item) && {label: 'Create notes file', action: () => this.editNotesFile(item, true)},
-              hasNotesFile && isPlayable(item) && {label: 'Edit notes file', action: () => this.editNotesFile(item, true)},
-              */
+              // !hasNotesFile && isPlayable(item) && {label: 'Create notes file', action: () => this.editNotesFile(item, true)},
+              // hasNotesFile && isPlayable(item) && {label: 'Edit notes file', action: () => this.editNotesFile(item, true)},
               canControlQueue && isPlayable(item) && {label: 'Remove from queue', action: () => this.unqueue(item)},
+              isTrack(item) && isQueued && {label: 'Reveal in queue', action: () => this.revealInQueue(item)},
               {divider: true},
-            ]),
 
-          ...(item === this.markGrouplike
-            ? [{label: 'Deselect all', action: () => this.unmarkAll()}]
-            : [
-              this.getMarkStatus(item) !== 'unmarked' && {label: 'Remove from selection', action: () => this.unmarkItem(item)},
-              this.getMarkStatus(item) !== 'marked' && {label: 'Add to selection', action: () => this.markItem(item)}
+              timestampsItem,
+              ...(item === this.markGrouplike
+                ? [{label: 'Deselect all', action: () => this.unmarkAll()}]
+                : [
+                  this.getMarkStatus(item) !== 'unmarked' && {label: 'Remove from selection', action: () => this.unmarkItem(item)},
+                  this.getMarkStatus(item) !== 'marked' && {label: 'Add to selection', action: () => this.markItem(item)}
+                ])
             ])
         ]
       }
@@ -1249,7 +1557,7 @@ class AppElement extends FocusElement {
     ].filter(Boolean)
 
     // TODO: Implement this! :P
-    const isMarked = false
+    // const isMarked = false
 
     this.showContextMenu({
       x: el.absLeft,
@@ -1526,9 +1834,9 @@ class AppElement extends FocusElement {
       } else if (input.isStop(keyBuf)) {
         this.actOnQueuePlayers(qp => qp.stopPlaying())
       } else if (input.isSkipBack(keyBuf)) {
-        this.actOnQueuePlayers(qp => qp.playPrevious(qp.playingTrack, true))
+        this.skipBackOrSeekToStart()
       } else if (input.isSkipAhead(keyBuf)) {
-        this.actOnQueuePlayers(qp => qp.playNext(qp.playingTrack, true))
+        this.skipAhead()
       }
     }
 
@@ -1593,11 +1901,6 @@ class AppElement extends FocusElement {
     })
   }
 
-  newPartyTab(socketId, partyGrouplike) {
-    const listing = this.newGrouplikeListing()
-    listing.loadGrouplike(partyGrouplike)
-  }
-
   cloneCurrentTab() {
     const grouplike = this.tabber.currentElement.grouplike
     const listing = this.newGrouplikeListing()
@@ -1651,30 +1954,45 @@ class AppElement extends FocusElement {
 
     const oldName = item.name
     if (isGroup(item)) {
-      if (order === 'shuffle') {
-        item = {
-          name: `${oldName} (shuffled)`,
-          items: shuffleArray(flattenGrouplike(item).items)
-        }
-      } else if (order === 'shuffle-groups') {
-        item = shuffleOrderOfGroups(item)
-        item.name = `${oldName} (group order shuffled)`
-      } else if (order === 'reverse') {
-        item = {
-          name: `${oldName} (reversed)`,
-          items: flattenGrouplike(item).items.reverse()
-        }
-      } else if (order === 'reverse-groups') {
-        item = reverseOrderOfGroups(item)
-        item.name = `${oldName} (group order reversed)`
-      } else if (order === 'alphabetic') {
-        item = {
-          name: `${oldName} (alphabetic)`,
-          items: orderBy(
-            flattenGrouplike(item).items,
-            t => getNameWithoutTrackNumber(t).replace(/[^a-zA-Z0-9]/g, '')
-          )
-        }
+      switch (order) {
+        case 'shuffle':
+          item = {
+            name: `${oldName} (shuffled)`,
+            items: shuffleArray(flattenGrouplike(item).items)
+          }
+          break
+        case 'shuffle-groups':
+          item = shuffleOrderOfGroups(item)
+          item.name = `${oldName} (group order shuffled)`
+          break
+        case 'reverse':
+          item = {
+            name: `${oldName} (reversed)`,
+            items: flattenGrouplike(item).items.reverse()
+          }
+          break
+        case 'reverse-groups':
+          item = reverseOrderOfGroups(item)
+          item.name = `${oldName} (group order reversed)`
+          break
+        case 'alphabetic':
+          item = {
+            name: `${oldName} (alphabetic)`,
+            items: orderBy(
+              flattenGrouplike(item).items,
+              t => getNameWithoutTrackNumber(t).replace(/[^a-zA-Z0-9]/g, '')
+            )
+          }
+          break
+        case 'alphabetic-groups':
+          item = {
+            name: `${oldName} (group order alphabetic)`,
+            items: orderBy(
+              collapseGrouplike(item).items,
+              t => t.name.replace(/[^a-zA-Z0-9]/g, '')
+            )
+          }
+          break
       }
     } else {
       // Make it into a grouplike that just contains itself.
@@ -1752,9 +2070,14 @@ class AppElement extends FocusElement {
       return
     }
 
-    const { playingTrack, timeData } = this.SQP
+    const { playingTrack, timeData, queueEndMode } = this.SQP
     const { items } = this.SQP.queueGrouplike
-    const { currentItem: selectedTrack } = this.queueListingElement
+    const {
+      currentInput: currentInput,
+      currentItem: selectedTrack
+    } = this.queueListingElement
+
+    const isTimestamp = (currentInput instanceof TimestampGrouplikeItemElement)
 
     let trackRemainSec = 0
     let trackPassedSec = 0
@@ -1767,6 +2090,7 @@ class AppElement extends FocusElement {
 
     const playingIndex = items.indexOf(playingTrack)
     const selectedIndex = items.indexOf(selectedTrack)
+    const timestampData = playingTrack && this.getTimestampData(playingTrack)
 
     // This will be set to a list of tracks, which will later be used to
     // calculate a particular duration (as described below) to be shown in
@@ -1789,7 +2113,10 @@ class AppElement extends FocusElement {
       durationRange = items
       durationAdd = 0
       durationSymbol = ''
-    } else if (selectedIndex === playingIndex) {
+    } else if (
+      selectedIndex === playingIndex &&
+      (!isTimestamp || currentInput.isCurrentTimestamp)
+    ) {
       // Remaining length of the queue.
       if (timeData) {
         durationRange = items.slice(playingIndex + 1)
@@ -1799,20 +2126,40 @@ class AppElement extends FocusElement {
         durationAdd = 0
       }
       durationSymbol = ''
-    } else if (selectedIndex < playingIndex) {
+    } else if (
+      selectedIndex < playingIndex ||
+      (isTimestamp && currentInput.data.timestamp <= trackPassedSec)
+    ) {
       // Time since the selected track ended.
       durationRange = items.slice(selectedIndex + 1, playingIndex)
       durationAdd = trackPassedSec // defaults to 0: no need to check timeData
       durationSymbol = '-'
-    } else if (selectedIndex > playingIndex) {
+      if (isTimestamp) {
+        if (selectedIndex < playingIndex) {
+          durationRange.unshift(items[selectedIndex])
+        }
+        durationAdd -= currentInput.data.timestampEnd
+      }
+    } else if (
+      selectedIndex > playingIndex ||
+      (isTimestamp && currentInput.data.timestamp > trackPassedSec)
+    ) {
       // Time until the selected track begins.
       if (timeData) {
-        durationRange = items.slice(playingIndex + 1, selectedIndex)
-        durationAdd = trackRemainSec
+        if (selectedIndex === playingIndex) {
+          durationRange = []
+          durationAdd = -trackPassedSec
+        } else {
+          durationRange = items.slice(playingIndex + 1, selectedIndex)
+          durationAdd = trackRemainSec
+        }
       } else {
         durationRange = items.slice(playingIndex, selectedIndex)
         durationAdd = 0
       }
+      if (isTimestamp) {
+        durationAdd += currentInput.data.timestamp
+      }
       durationSymbol = '+'
     }
 
@@ -1823,28 +2170,117 @@ class AppElement extends FocusElement {
     const { duration: durationString } = getTimeStringsFromSec(0, durationTotal)
     this.queueTimeLabel.text = `(${durationSymbol + durationString + approxSymbol})`
 
-    let collapseExtraInfo = false
     if (playingTrack) {
-      let insertString
-      const distance = Math.abs(selectedIndex - playingIndex)
-      if (selectedIndex < playingIndex) {
-        insertString = ` (-${distance})`
-        collapseExtraInfo = true
-      } else if (selectedIndex > playingIndex) {
-        insertString = ` (+${distance})`
-        collapseExtraInfo = true
-      } else {
-        insertString = ''
+      let trackPart
+      let trackPartShort
+      let trackPartReallyShort
+
+      {
+        const distance = Math.abs(selectedIndex - playingIndex)
+
+        let insertString
+        let insertStringShort
+        if (selectedIndex < playingIndex) {
+          insertString = ` (-${distance})`
+          insertStringShort = `-${distance}`
+        } else if (selectedIndex > playingIndex) {
+          insertString = ` (+${distance})`
+          insertStringShort = `+${distance}`
+        } else {
+          insertString = ''
+          insertStringShort = ''
+        }
+
+        trackPart = `${playingIndex + 1 + insertString} / ${items.length}`
+        trackPartShort = (insertString
+          ? `${playingIndex + 1 + insertStringShort}/${items.length}`
+          : `${playingIndex + 1}/${items.length}`)
+        trackPartReallyShort = (insertString
+          ? insertStringShort
+          : `#${playingIndex + 1}`)
       }
-      this.queueLengthLabel.text = `(${this.SQP.playSymbol} ${playingIndex + 1 + insertString} / ${items.length})`
+
+      let timestampPart
+
+      if (isTimestamp && selectedIndex === playingIndex) {
+        const selectedTimestampIndex = timestampData.indexOf(currentInput.data)
+
+        const found = timestampData.findIndex(ts => ts.timestamp > trackPassedSec)
+        const playingTimestampIndex = (found >= 0 ? found - 1 : 0)
+        const distance = Math.abs(selectedTimestampIndex - playingTimestampIndex)
+
+        let insertString
+        if (selectedTimestampIndex < playingTimestampIndex) {
+          insertString = ` (-${distance})`
+        } else if (selectedTimestampIndex > playingTimestampIndex) {
+          insertString = ` (+${distance})`
+        } else {
+          insertString = ''
+        }
+
+        timestampPart = `${playingTimestampIndex + 1 + insertString} / ${timestampData.length}`
+      }
+
+      let queueLoopPart
+      let queueLoopPartShort
+
+      if (selectedIndex === playingIndex) {
+        switch (queueEndMode) {
+          case 'loop':
+            queueLoopPart = 'Repeat'
+            queueLoopPartShort = 'R'
+            break
+          case 'shuffle':
+            queueLoopPart = 'Shuffle'
+            queueLoopPartShort = 'S'
+            break
+          case 'end':
+          default:
+            break
+        }
+      }
+
+      let partsTogether
+
+      const all = () => `(${this.SQP.playSymbol} ${partsTogether})`
+      const tooWide = () => all().length > this.queuePane.contentW
+
+      // goto irl
+      determineParts: {
+        if (timestampPart) {
+          if (queueLoopPart) {
+            partsTogether = `${trackPart} : ${timestampPart} »${queueLoopPartShort}`
+          } else {
+            partsTogether = `(${this.SQP.playSymbol} ${trackPart} : ${timestampPart})`
+          }
+          break determineParts
+        }
+
+        if (queueLoopPart) includeQueueLoop: {
+          partsTogether = `${trackPart} » ${queueLoopPart}`
+          if (tooWide()) {
+            partsTogether = `${trackPart} »${queueLoopPartShort}`
+            if (tooWide()) {
+              break includeQueueLoop
+            }
+          }
+          break determineParts
+        }
+
+        partsTogether = trackPart
+        if (tooWide()) {
+          partsTogether = trackPartShort
+          if (tooWide()) {
+            partsTogether = trackPartReallyShort
+          }
+        }
+      }
+
+      this.queueLengthLabel.text = all()
     } else {
       this.queueLengthLabel.text = `(${items.length})`
     }
 
-    if (this.SQP.loopQueueAtEnd) {
-      this.queueLengthLabel.text += (collapseExtraInfo ? ` [L${unic.ELLIPSIS}]` : ` [Looping]`)
-    }
-
     // Layout stuff to position the length and time labels correctly.
     this.queueLengthLabel.centerInParent()
     this.queueTimeLabel.centerInParent()
@@ -1852,13 +2288,65 @@ class AppElement extends FocusElement {
     this.queueTimeLabel.y = this.queuePane.contentH - 1
   }
 
+  updateQueueSelection(timeData, oldTimeData) {
+    if (!timeData) {
+      return
+    }
+
+    const { playingTrack } = this.SQP
+    const { form } = this.queueListingElement
+    const { currentInput } = form
+
+    if (!currentInput || currentInput.item !== playingTrack) {
+      return
+    }
+
+    const timestamps = this.getTimestampData(playingTrack)
+
+    if (!timestamps) {
+      return
+    }
+
+    const tsOld = oldTimeData &&
+      this.getTimestampAtSec(playingTrack, oldTimeData.curSecTotal)
+    const tsNew =
+      this.getTimestampAtSec(playingTrack, timeData.curSecTotal)
+
+    if (
+      tsNew !== tsOld &&
+      currentInput instanceof TimestampGrouplikeItemElement &&
+      currentInput.data === tsOld
+    ) {
+      const index = form.inputs.findIndex(el => (
+        el.item === playingTrack &&
+        el instanceof TimestampGrouplikeItemElement &&
+        el.data === tsNew
+      ))
+
+      if (index === -1) {
+        return
+      }
+
+      form.curIndex = index
+      if (form.isSelected) {
+        form.updateSelectedElement()
+      }
+      form.scrollSelectedElementIntoView()
+    }
+  }
+
+  setThemeColor(color) {
+    this.themeColor = color
+    this.menubar.color = color
+  }
+
   get SQP() {
     // Just a convenient shorthand.
     return this.selectedQueuePlayer
   }
 
   get selectedQueuePlayer() { return this.getDep('selectedQueuePlayer') }
-  set selectedQueuePlayer(v) { return this.setDep('selectedQueuePlayer', v) }
+  set selectedQueuePlayer(v) { this.setDep('selectedQueuePlayer', v) }
 }
 
 class GrouplikeListingElement extends Form {
@@ -1923,6 +2411,7 @@ class GrouplikeListingElement extends Form {
     this.grouplikeData = new WeakMap()
 
     this.autoscrollOffset = null
+    this.expandedTimestamps = []
   }
 
   getNewForm() {
@@ -2011,7 +2500,8 @@ class GrouplikeListingElement extends Form {
     if (isGroup(this.grouplike)) {
       this.grouplikeData.set(this.grouplike, {
         scrollItems: this.form.scrollItems,
-        currentItem: this.currentItem
+        currentItem: this.currentItem,
+        expandedTimestamps: this.expandedTimestamps
       })
     }
   }
@@ -2022,6 +2512,8 @@ class GrouplikeListingElement extends Form {
       this.form.scrollItems = data.scrollItems
       this.form.selectAndShow(data.currentItem)
       this.form.fixLayout()
+      this.expandedTimestamps = data.expandedTimestamps
+      this.buildTimestampItems()
     }
   }
 
@@ -2097,6 +2589,186 @@ class GrouplikeListingElement extends Form {
     }
   }
 
+  expandTimestamps(item) {
+    if (this.grouplike && this.grouplike.items.includes(item)) {
+      const ET = this.expandedTimestamps
+      if (!ET.includes(item)) {
+        this.expandedTimestamps.push(item)
+        this.buildTimestampItems()
+
+        if (this.currentItem === item) {
+          if (this.isSelected) {
+            this.form.selectInput(this.form.inputs[this.form.curIndex + 1])
+          } else {
+            this.form.curIndex += 1
+          }
+        }
+      }
+    }
+  }
+
+  collapseTimestamps(item) {
+    const ET = this.expandedTimestamps // :alien:
+    if (ET.includes(item)) {
+      const restore = (this.currentItem === item)
+
+      ET.splice(ET.indexOf(item), 1)
+      this.buildTimestampItems()
+
+      if (restore) {
+        const { form } = this
+        const index = form.inputs.findIndex(inp => inp.item === item)
+        form.curIndex = index
+        if (form.isSelected) {
+          form.updateSelectedElement()
+        }
+        form.scrollSelectedElementIntoView()
+      }
+    }
+  }
+
+  toggleTimestamps(item) {
+    if (this.timestampsExpanded(item)) {
+      this.collapseTimestamps(item)
+    } else {
+      this.expandTimestamps(item)
+    }
+  }
+
+  timestampsExpanded(item) {
+    this.updateTimestamps()
+    return this.expandedTimestamps.includes(item)
+  }
+
+  selectTimestampAtSec(item, sec) {
+    this.expandTimestamps(item)
+
+    const { form } = this
+    let index = form.inputs.findIndex(el => (
+      el.item === item &&
+      el instanceof TimestampGrouplikeItemElement &&
+      el.data.timestamp >= sec
+    ))
+
+    if (index === -1) {
+      index = form.inputs.findIndex(el => el.item === item)
+      if (index === -1) {
+        return
+      }
+    }
+
+    form.curIndex = index
+    if (form.isSelected) {
+      form.updateSelectedElement()
+    }
+    form.scrollSelectedElementIntoView()
+  }
+
+  updateTimestamps() {
+    const ET = this.expandedTimestamps
+    if (ET) {
+      this.expandedTimestamps = ET.filter(item => this.grouplike.items.includes(item))
+    }
+  }
+
+  restoreSelectedInput(restoreInput) {
+    const { form } = this
+    const { inputs, currentInput } = form
+
+    if (currentInput === restoreInput) {
+      return
+    }
+
+    let inputToSelect
+
+    if (inputs.includes(restoreInput)) {
+      inputToSelect = restoreInput
+    } else if (restoreInput instanceof InteractiveGrouplikeItemElement) {
+      inputToSelect = inputs.find(input =>
+        input.item === restoreInput.item &&
+        input instanceof InteractiveGrouplikeItemElement
+      )
+    } else if (restoreInput instanceof TimestampGrouplikeItemElement) {
+      inputToSelect = inputs.find(input =>
+        input.data === restoreInput.data &&
+        input instanceof TimestampGrouplikeItemElement
+      )
+    }
+
+    if (!inputToSelect) {
+      return
+    }
+
+    form.curIndex = inputs.indexOf(inputToSelect)
+    if (form.isSelected) {
+      form.updateSelectedElement()
+    }
+    form.scrollSelectedElementIntoView()
+  }
+
+  buildTimestampItems(restoreInput = this.currentInput) {
+    const form = this.form
+
+    // Clear up any existing timestamp items, since we're about to generate new
+    // ones!
+    form.children = form.children.filter(child => !(child instanceof TimestampGrouplikeItemElement))
+    form.inputs = form.inputs.filter(child => !(child instanceof TimestampGrouplikeItemElement))
+
+    this.updateTimestamps()
+
+    if (!this.expandedTimestamps) {
+      // Well that's going to have obvious consequences.
+      return
+    }
+
+    for (const item of this.expandedTimestamps) {
+      // Find the main item element. The items we're about to generate will be
+      // inserted after it.
+      const mainElementIndex = form.inputs.findIndex(el => (
+        el instanceof InteractiveGrouplikeItemElement &&
+        el.item === item
+      ))
+
+      const timestampData = this.app.getTimestampData(item)
+
+      // Oh no.
+      // TODO: This should probably error report lol.
+      if (!timestampData) {
+        continue
+      }
+
+      // Generate some items! Just go over the data list and generate one for
+      // each timestamp.
+      const tsElements = timestampData.map(ts => {
+        const el = new TimestampGrouplikeItemElement(item, ts, timestampData, this.app)
+        el.on('pressed', () => this.emit('timestamp', item, ts.timestamp))
+        if (this.grouplike.isTheQueue) {
+          el.hideMetadata = true
+        }
+        return el
+      })
+
+      // Stick 'em in. Form doesn't implement an "insert input" function because
+      // why would life be easy, so we'll mangle the inputs array ourselves.
+
+      form.inputs.splice(mainElementIndex + 1, 0, ...tsElements)
+
+      let previousIndex = mainElementIndex
+      for (const el of tsElements) {
+        // We do addChild rather than a simple splice because addChild does more
+        // stuff than just sticking it in the array (e.g. setting the child's
+        // .parent property). What if addInput gets updated to do more stuff in
+        // a similar fashion? Well, then we're scr*wed! :)
+        form.addChild(el, previousIndex + 1)
+        previousIndex++
+      }
+    }
+
+    this.restoreSelectedInput(restoreInput)
+    this.scheduleDrawWithoutPropertyChange()
+    this.fixAllLayout()
+  }
+
   buildItems(resetIndex = false) {
     if (!this.grouplike) {
       throw new Error('Attempted to call buildItems before a grouplike was loaded')
@@ -2104,6 +2776,7 @@ class GrouplikeListingElement extends Form {
 
     this.commentLabel.text = this.grouplike.comment || ''
 
+    const restoreInput = this.form.currentInput
     const wasSelected = this.isSelected
     const form = this.form
 
@@ -2164,8 +2837,11 @@ class GrouplikeListingElement extends Form {
       }
     }
 
+    this.buildTimestampItems(restoreInput)
+
     // Just to make the selected-track-info bar fill right away (if it wasn't
     // already filled by a previous this.curIndex set).
+    /* eslint-disable-next-line no-self-assign */
     form.curIndex = form.curIndex
 
     this.fixAllLayout()
@@ -2187,6 +2863,8 @@ class GrouplikeListingElement extends Form {
       itemElement.on(evtName, (...data) => this.emit(evtName, itemElement.item, ...data))
     }
 
+    itemElement.on('toggle-timestamps', () => this.toggleTimestamps(itemElement.item))
+
     /*
     itemElement.on('unselected labels', () => {
       if (!this.expandLabels) {
@@ -2295,9 +2973,13 @@ class GrouplikeListingElement extends Form {
   }
 
   get currentItem() {
-    const element = this.form.inputs[this.form.curIndex] || null
+    const element = this.currentInput
     return element && element.item
   }
+
+  get currentInput() {
+    return this.form.currentInput
+  }
 }
 
 class GrouplikeListingForm extends ListScrollForm {
@@ -2312,6 +2994,10 @@ class GrouplikeListingForm extends ListScrollForm {
   }
 
   keyPressed(keyBuf) {
+    if (this.inputs.length === 0) {
+      return
+    }
+
     if (input.isSelectUp(keyBuf)) {
       this.selectUp()
     } else if (input.isSelectDown(keyBuf)) {
@@ -2327,7 +3013,6 @@ class GrouplikeListingForm extends ListScrollForm {
   set curIndex(newIndex) {
     this.setDep('curIndex', newIndex)
     this.emit('select', this.inputs[this.curIndex])
-    return newIndex
   }
 
   get curIndex() {
@@ -2338,6 +3023,10 @@ class GrouplikeListingForm extends ListScrollForm {
     return Math.max(0, this.inputs.findIndex(el => el instanceof InteractiveGrouplikeItemElement))
   }
 
+  get currentInput() {
+    return this.inputs[this.curIndex]
+  }
+
   selectAndShow(item) {
     const index = this.inputs.findIndex(inp => inp.item === item)
     if (index >= 0) {
@@ -2414,7 +3103,6 @@ class GrouplikeListingForm extends ListScrollForm {
   }
 
   dragLeftRange(item) {
-    const { items } = this.app.markGrouplike
     if (this.selectMode === 'select') {
       if (!this.oldMarkedItems.includes(item)) {
         this.app.unmarkItem(item)
@@ -2636,13 +3324,27 @@ class InlineListPickerElement extends FocusElement {
   // next or previous. (That's the point, it's inline.) This element is mainly
   // useful in forms or ContextMenus.
 
-  constructor(labelText, options, showContextMenu = null) {
+  constructor(labelText, options, optsOrShowContextMenu = null) {
     super()
+
     this.labelText = labelText
     this.options = options
-    this.showContextMenu = showContextMenu
-    this.curIndex = 0
+
+    if (typeof optsOrShowContextMenu === 'function') {
+      this.showContextMenu = optsOrShowContextMenu
+    }
+
+    if (typeof optsOrShowContextMenu === 'object') {
+      const opts = optsOrShowContextMenu
+      this.showContextMenu = opts.showContextMenu
+      this.getValue = opts.getValue
+      this.setValue = opts.setValue
+    }
+
     this.keyboardIdentifier = this.labelText
+
+    this.curIndex = 0
+    this.refreshValue()
   }
 
   fixLayout() {
@@ -2693,17 +3395,7 @@ class InlineListPickerElement extends FocusElement {
     } else if (telc.isLeft(keyBuf)) {
       this.previousOption()
     } else if (input.isMenu(keyBuf) && this.showContextMenu) {
-      this.showContextMenu({
-        x: this.absLeft + ansi.measureColumns(this.labelText) + 1,
-        y: this.absTop + 1,
-        items: this.options.map(({ value, label }, index) => ({
-          label: label,
-          action: () => {
-            this.curIndex = index
-          },
-          isDefault: index === this.curIndex
-        }))
-      })
+      this.showMenu()
     } else {
       return true
     }
@@ -2717,6 +3409,8 @@ class InlineListPickerElement extends FocusElement {
       } else {
         this.root.select(this)
       }
+    } else if (button === 'right') {
+      this.showMenu()
     } else if (button === 'scroll-up') {
       this.previousOption()
     } else if (button === 'scroll-down') {
@@ -2727,11 +3421,40 @@ class InlineListPickerElement extends FocusElement {
     return false
   }
 
+
+  showMenu() {
+    this.showContextMenu({
+      x: this.absLeft + ansi.measureColumns(this.labelText) + 1,
+      y: this.absTop + 1,
+      items: this.options.map(({ label }, index) => ({
+        label,
+        action: () => {
+          this.curIndex = index
+        },
+        isDefault: index === this.curIndex
+      }))
+    })
+  }
+
+  refreshValue() {
+    if (this.getValue) {
+      const value = this.getValue()
+      const index = this.options.findIndex(opt => opt.value === value)
+      if (index >= 0) {
+        this.curIndex = index
+      }
+    }
+  }
+
   nextOption() {
     this.curIndex++
     if (this.curIndex === this.options.length) {
       this.curIndex = 0
     }
+
+    if (this.setValue) {
+      this.setValue(this.curValue)
+    }
   }
 
   previousOption() {
@@ -2739,6 +3462,10 @@ class InlineListPickerElement extends FocusElement {
     if (this.curIndex < 0) {
       this.curIndex = this.options.length - 1
     }
+
+    if (this.setValue) {
+      this.setValue(this.curValue)
+    }
   }
 
   get curValue() {
@@ -2746,7 +3473,7 @@ class InlineListPickerElement extends FocusElement {
   }
 
   get curIndex() { return this.getDep('curIndex') }
-  set curIndex(v) { return this.setDep('curIndex', v) }
+  set curIndex(v) { this.setDep('curIndex', v) }
 }
 
 // Quite hacky, but ATM I can't think of any way to neatly tie getDep/setDep
@@ -2936,6 +3663,9 @@ class ToggleControl extends FocusElement {
   }
 
 
+  // Note: ToggleControl doesn't specify refreshValue because it doesn't have an
+  // internal state for the current value. It sets and draws based on the value
+  // getter provided externally.
   toggle() {
     this.setValue(!this.getValue())
   }
@@ -3110,6 +3840,8 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
     } else if (telc.isEnter(keyBuf)) {
       if (isGroup(this.item)) {
         this.emit('browse')
+      } else if (this.app.hasTimestampsFile(this.item)) {
+        this.emit('toggle-timestamps')
       } else if (isTrack(this.item)) {
         this.emit('queue', {where: 'next', play: true})
       } else if (!this.isPlayable) {
@@ -3180,15 +3912,16 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
 
   writeStatus(writable) {
     const markStatus = this.app.getMarkStatus(this.item)
+    const color = this.app.themeColor + 30
 
     if (this.isGroup) {
       // The ANSI attributes here will apply to the rest of the line, too.
       // (We don't reset the active attributes until after drawing the rest of
       // the line.)
       if (markStatus === 'marked' || markStatus === 'partial') {
-        writable.write(ansi.setAttributes([ansi.C_BLUE + 10]))
+        writable.write(ansi.setAttributes([color + 10]))
       } else {
-        writable.write(ansi.setAttributes([ansi.C_BLUE, ansi.A_BRIGHT]))
+        writable.write(ansi.setAttributes([color, ansi.A_BRIGHT]))
       }
     } else if (this.isTrack) {
       if (markStatus === 'marked') {
@@ -3222,9 +3955,11 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
     } else if (!this.isPlayable) {
       writable.write('F')
     } else if (record.downloading) {
-      writable.write(braille[Math.floor(Date.now() / 250) % 6])
+      writable.write(brailleChar)
     } else if (this.app.SQP.playingTrack === this.item) {
       writable.write('\u25B6')
+    } else if (this.app.hasTimestampsFile(this.item)) {
+      writable.write(':')
     } else {
       writable.write(' ')
     }
@@ -3245,6 +3980,102 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
   }
 }
 
+class TimestampGrouplikeItemElement extends BasicGrouplikeItemElement {
+  constructor(item, timestampData, tsDataArray, app) {
+    super('')
+
+    this.app = app
+    this.data = timestampData
+    this.tsData = tsDataArray
+    this.item = item
+    this.hideMetadata = false
+  }
+
+  drawTo(writable) {
+    const { data, tsData } = this
+
+    const metadata = this.app.backend.getMetadataFor(this.item)
+    const last = tsData[tsData.length - 1]
+    const duration = ((metadata && metadata.duration)
+      || last.timestampEnd !== Infinity && last.timestampEnd
+      || last.timestamp)
+    const strings = getTimeStringsFromSec(data.timestamp, duration)
+
+    this.text = (
+      /*
+      (trackDuration
+        ? `(${strings.timeDone} - ${strings.percentDone})`
+        : `(${strings.timeDone})`) +
+        */
+      `(${strings.timeDone})` +
+      (data.comment
+        ? ` ${data.comment}`
+        : '')
+    )
+
+    if (!this.hideMetadata) {
+      const durationString = (data.timestampEnd === Infinity
+        ? 'to end'
+        : getTimeStringsFromSec(0, data.timestampEnd - data.timestamp).duration)
+
+      // Try to line up so there's one column of negative padding - the duration
+      // of the timestamp(s) should start one column before the duration of the
+      // actual track. This makes for a nice nested look!
+      const rightPadding = ' '.repeat(duration > 3600 ? 4 : 2)
+      this.rightText = ` (${durationString})` + rightPadding
+    }
+
+    super.drawTo(writable)
+  }
+
+  writeStatus(writable) {
+    let parts = []
+
+    const color = ansi.setAttributes([ansi.A_BRIGHT, 30 + this.app.themeColor])
+    const reset = ansi.setAttributes([ansi.C_RESET])
+
+    if (this.isCurrentTimestamp) {
+      parts = [
+        color,
+        ' ',
+        // reset,
+        '\u25B6 ',
+        // color,
+        ' '
+      ]
+    } else {
+      parts = [
+        color,
+        '  ',
+        reset,
+        ':',
+        color,
+        ' '
+      ]
+    }
+
+    for (const part of parts) {
+      writable.write(part)
+    }
+
+    this.drawX += 4
+  }
+
+  get isCurrentTimestamp() {
+    const { SQP } = this.app
+    return (
+      SQP.playingTrack === this.item &&
+      SQP.timeData &&
+      SQP.timeData.curSecTotal >= this.data.timestamp &&
+      SQP.timeData.curSecTotal < this.data.timestampEnd
+    )
+  }
+
+  getLeftPadding() {
+    return 4
+  }
+}
+
 class ListingJumpElement extends Form {
   constructor() {
     super()
@@ -3582,7 +4413,6 @@ class PlaybackInfoElement extends FocusElement {
         this.app.backend.queuePlayers.length > 1 && {
           label: 'Delete',
           action: () => {
-            const { parent } = this
             this.app.removeQueuePlayer(this.queuePlayer)
           }
         }
@@ -3609,9 +4439,14 @@ class PlaybackInfoElement extends FocusElement {
     this.isLooping = player.isLooping
     this.isPaused = player.isPaused
 
-    this.progressBarLabel.text = '-'.repeat(Math.floor(this.w / lenSecTotal * curSecTotal))
+    if (duration) {
+      this.progressBarLabel.text = '-'.repeat(Math.floor(this.w / lenSecTotal * curSecTotal))
+      this.progressTextLabel.text = timeDone + ' / ' + duration
+    } else {
+      this.progressBarLabel.text = ''
+      this.progressTextLabel.text = timeDone
+    }
 
-    this.progressTextLabel.text = timeDone + ' / ' + duration
     if (player.isLooping) {
       this.progressTextLabel.text += ' [Looping]'
     }
@@ -3683,17 +4518,17 @@ class PlaybackInfoElement extends FocusElement {
   }
 
   get curSecTotal() { return this.getDep('curSecTotal') }
-  set curSecTotal(v) { return this.setDep('curSecTotal', v) }
+  set curSecTotal(v) { this.setDep('curSecTotal', v) }
   get lenSecTotal() { return this.getDep('lenSecTotal') }
-  set lenSecTotal(v) { return this.setDep('lenSecTotal', v) }
+  set lenSecTotal(v) { this.setDep('lenSecTotal', v) }
   get volume() { return this.getDep('volume') }
-  set volume(v) { return this.setDep('volume', v) }
+  set volume(v) { this.setDep('volume', v) }
   get isLooping() { return this.getDep('isLooping') }
-  set isLooping(v) { return this.setDep('isLooping', v) }
+  set isLooping(v) { this.setDep('isLooping', v) }
   get isPaused() { return this.getDep('isPaused') }
-  set isPaused(v) { return this.setDep('isPaused', v) }
+  set isPaused(v) { this.setDep('isPaused', v) }
   get currentTrack() { return this.getDep('currentTrack') }
-  set currentTrack(v) { return this.setDep('currentTrack', v) }
+  set currentTrack(v) { this.setDep('currentTrack', v) }
 }
 
 class OpenPlaylistDialog extends Dialog {
@@ -4103,6 +4938,16 @@ class ContextMenu extends FocusElement {
       return
     }
 
+    // Call refreshValue() on any items before they're shown, for items that
+    // provide it. (This is handy when reusing the same input across a menu that
+    // might be shown under different contexts.)
+    for (const item of items) {
+      const el = item.element
+      if (!el) continue
+      if (!el.refreshValue) continue
+      el.refreshValue()
+    }
+
     if (!this.root.selectedElement.directAncestors.includes(this)) {
       this.selectedBefore = this.root.selectedElement
     }
@@ -4454,9 +5299,14 @@ class Menubar extends ListScrollForm {
 
     if (this.keyboardSelector.keyPressed(keyBuf)) {
       return false
-    } else if (telc.isCaselessLetter(keyBuf, 'c')) {
+    } else if (input.isNextThemeColor(keyBuf)) {
       // For fun :)
-      this.color = (this.color % 8) + 1
+      this.color = (this.color === 8 ? 1 : this.color + 1)
+      this.emit('color', this.color)
+      return false
+    } else if (input.isPreviousThemeColor(keyBuf)) {
+      this.color = (this.color === 1 ? 8 : this.color - 1)
+      this.emit('color', this.color)
       return false
     } else if (telc.isCaselessLetter(keyBuf, 'a')) {
       this.attribute = (this.attribute % 3) + 1
@@ -4513,9 +5363,9 @@ class Menubar extends ListScrollForm {
   }
 
   get color() { return this.getDep('color') }
-  set color(v) { return this.setDep('color', v) }
+  set color(v) { this.setDep('color', v) }
   get attribute() { return this.getDep('attribute') }
-  set attribute(v) { return this.setDep('attribute', v) }
+  set attribute(v) { this.setDep('attribute', v) }
 }
 
 class PartyBanner extends DisplayElement {
@@ -4680,7 +5530,7 @@ class Log extends ListScrollForm {
     this.addInput(logMessage)
     this.fixLayout()
     this.scrollToEnd()
-    this.emit('log-message', logMessage)
+    this.emit('log message', logMessage)
     return logMessage
   }
 }
@@ -4745,5 +5595,3 @@ class LogMessageLabel extends WrapLabel {
     ].filter(x => x !== null)
   }
 }
-
-module.exports = AppElement
diff --git a/undo-manager.js b/undo-manager.js
index 4a042ad..9b53c2d 100644
--- a/undo-manager.js
+++ b/undo-manager.js
@@ -1,4 +1,4 @@
-class UndoManager {
+export default class UndoManager {
   constructor() {
     this.actionStack = []
     this.undoneStack = []
@@ -38,5 +38,3 @@ class UndoManager {
     return this.undoStack.length === 0
   }
 }
-
-module.exports = UndoManager