« get me outta code hell

WIP - support multiple players at once - mtui - Music Text User Interface - user-friendly command line music player
about summary refs log tree commit diff
diff options
context:
space:
mode:
authorFlorrie <towerofnix@gmail.com>2019-09-20 16:54:13 -0300
committerFlorrie <towerofnix@gmail.com>2019-09-20 16:54:13 -0300
commit6bad90e8e0db9c9273de984be53a1ca61b4d8a24 (patch)
tree45e5f2a74a4fe9f6d939590a5d547be939071596
parenta5d3b710eb46e58708b8dbb51f5231ba534561fd (diff)
WIP - support multiple players at once
Currently bug-free and doesn't change anything about existing mtui
behavior! Meta N to create a new player, meta up/down to switch between
which one you're interacting with. Each player has its own queue.
Eventually (soon(TM)) there'll be much better UI to go with all this!
-rw-r--r--backend.js321
-rw-r--r--telnet-server.js1
-rw-r--r--todo.txt4
m---------tui-lib0
-rw-r--r--ui.js230
5 files changed, 350 insertions, 206 deletions
diff --git a/backend.js b/backend.js
index 57910e9..e8601c5 100644
--- a/backend.js
+++ b/backend.js
@@ -28,24 +28,22 @@ const fs = require('fs')
 const writeFile = promisify(fs.writeFile)
 const readFile = promisify(fs.readFile)
 
-class Backend extends EventEmitter {
-  constructor() {
+class QueuePlayer extends EventEmitter {
+  constructor({
+    getRecordFor
+  }) {
     super()
 
     this.player = null
     this.playingTrack = null
-    this.recordStore = new RecordStore()
-    this.throttleMetadata = throttlePromise(10)
-    this.metadataDictionary = {}
     this.queueGrouplike = {name: 'Queue', isTheQueue: true, items: []}
     this.pauseNextTrack = false
     this.playedTrackToEnd = false
+    this.timeData = null
 
-    this.rootDirectory = process.env.HOME + '/.mtui'
-    this.metadataPath = this.rootDirectory + '/track-metadata.json'
+    this.getRecordFor = getRecordFor
   }
 
-
   async setup() {
     this.player = await getPlayer()
 
@@ -55,79 +53,16 @@ class Backend extends EventEmitter {
       }
     }
 
-    await this.loadMetadata()
-
     this.player.on('printStatusLine', data => {
       if (this.playingTrack) {
-        this.emit('printStatusLine', data)
+        this.timeData = data
+        this.emit('received time data', data, this)
       }
     })
 
     return true
   }
 
-
-  async readMetadata() {
-    try {
-      return JSON.parse(await readFile(this.metadataPath))
-    } catch (error) {
-      // Just stop. It's okay to fail to load metadata.
-      return null
-    }
-  }
-
-  async loadMetadata() {
-    Object.assign(this.metadataDictionary, await this.readMetadata())
-  }
-
-  async saveMetadata() {
-    const newData = Object.assign({}, await this.readMetadata(), this.metadataDictionary)
-    await writeFile(this.metadataPath, JSON.stringify(newData))
-  }
-
-  getMetadataFor(item) {
-    const key = this.metadataDictionary[item.downloaderArg]
-    return this.metadataDictionary[key] || null
-  }
-
-  async processMetadata(item, reprocess = false, top = true) {
-    let counter = 0
-
-    if (isGroup(item)) {
-      const results = await Promise.all(item.items.map(x => this.processMetadata(x, reprocess, false)))
-      counter += results.reduce((acc, n) => acc + n, 0)
-    } else process: {
-      if (!reprocess && this.getMetadataFor(item)) {
-        break process
-      }
-
-      await this.throttleMetadata(async () => {
-        const filePath = await this.download(item)
-        const metadataReader = getMetadataReaderFor(filePath)
-        const data = await metadataReader(filePath)
-
-        this.metadataDictionary[item.downloaderArg] = filePath
-        this.metadataDictionary[filePath] = data
-      })
-
-      this.emit('processMetadata progress', this.throttleMetadata.queue.length)
-
-      counter++
-    }
-
-    if (top) {
-      await this.saveMetadata()
-    }
-
-    return counter
-  }
-
-
-  getRecordFor(item) {
-    return this.recordStore.getRecord(item)
-  }
-
-
   queue(topItem, afterItem = null, {movePlayingTrack = true} = {}) {
     const { items } = this.queueGrouplike
     const newTrackIndex = items.length
@@ -389,49 +324,9 @@ class Backend extends EventEmitter {
     this.emit('queue updated')
   }
 
-  seekAhead(seconds) {
-    this.player.seekAhead(seconds)
-  }
-
-  seekBack(seconds) {
-    this.player.seekBack(seconds)
-  }
-
-  togglePause() {
-    this.player.togglePause()
-  }
-
-  setPause(value) {
-    this.player.setPause(value)
-  }
-
-  toggleLoop() {
-    this.player.toggleLoop()
-  }
-
-  setLoop(value) {
-    this.player.setLoop(value)
-  }
-
-  volUp(amount = 10) {
-    this.player.volUp(amount)
-  }
-
-  volDown(amount = 10) {
-    this.player.volDown(amount)
-  }
-
-  setVolume(value) {
-    this.player.setVolume(value)
-  }
-
-  setPauseNextTrack(value) {
-    this.pauseNextTrack = !!value
-  }
-
   async stopPlaying() {
-    // We emit this so playTrack doesn't immediately start a new track.
-    // We aren't *actually* about to play a new track.
+    // We emit this so the active play() call doesn't immediately start a new
+    // track. We aren't *actually* about to play a new track.
     this.emit('playing new track')
     await this.player.kill()
     this.clearPlayingTrack()
@@ -559,10 +454,185 @@ class Backend extends EventEmitter {
     if (this.playingTrack !== null) {
       const oldTrack = this.playingTrack
       this.playingTrack = null
+      this.timeData = null
       this.emit('playing', null, oldTrack)
     }
   }
 
+  async download(item) {
+    if (isGroup(item)) {
+      // TODO: Download all children (recursively), show a confirmation prompt
+      // if there are a lot of items (remember to flatten).
+      return
+    }
+
+    // Don't start downloading an item if we're already downloading it!
+    if (this.getRecordFor(item).downloading) {
+      return
+    }
+
+    const arg = item.downloaderArg
+    this.getRecordFor(item).downloading = true
+    try {
+      return await getDownloaderFor(arg)(arg)
+    } finally {
+      this.getRecordFor(item).downloading = false
+    }
+  }
+
+  seekAhead(seconds) {
+    this.player.seekAhead(seconds)
+  }
+
+  seekBack(seconds) {
+    this.player.seekBack(seconds)
+  }
+
+  togglePause() {
+    this.player.togglePause()
+  }
+
+  setPause(value) {
+    this.player.setPause(value)
+  }
+
+  toggleLoop() {
+    this.player.toggleLoop()
+  }
+
+  setLoop(value) {
+    this.player.setLoop(value)
+  }
+
+  volUp(amount = 10) {
+    this.player.volUp(amount)
+  }
+
+  volDown(amount = 10) {
+    this.player.volDown(amount)
+  }
+
+  setVolume(value) {
+    this.player.setVolume(value)
+  }
+
+  setPauseNextTrack(value) {
+    this.pauseNextTrack = !!value
+  }
+
+  get playSymbol() {
+    if (this.player && this.playingTrack) {
+      if (this.player.isPaused) {
+        return '⏸'
+      } else {
+        return '▶'
+      }
+    } else {
+      return '.'
+    }
+  }
+}
+
+class Backend extends EventEmitter {
+  constructor() {
+    super()
+
+    this.queuePlayers = []
+
+    this.recordStore = new RecordStore()
+    this.throttleMetadata = throttlePromise(10)
+    this.metadataDictionary = {}
+
+    this.rootDirectory = process.env.HOME + '/.mtui'
+    this.metadataPath = this.rootDirectory + '/track-metadata.json'
+  }
+
+  async setup() {
+    const error = await this.addQueuePlayer()
+    if (error.error) {
+      return error
+    }
+
+    await this.loadMetadata()
+
+    return true
+  }
+
+  async addQueuePlayer() {
+    const queuePlayer = new QueuePlayer({
+      getRecordFor: item => this.getRecordFor(item)
+    })
+
+    const error = await queuePlayer.setup()
+    if (error.error) {
+      return error
+    }
+
+    this.queuePlayers.push(queuePlayer)
+    this.emit('added queue player', queuePlayer)
+
+    return queuePlayer
+  }
+
+  async readMetadata() {
+    try {
+      return JSON.parse(await readFile(this.metadataPath))
+    } catch (error) {
+      // Just stop. It's okay to fail to load metadata.
+      return null
+    }
+  }
+
+  async loadMetadata() {
+    Object.assign(this.metadataDictionary, await this.readMetadata())
+  }
+
+  async saveMetadata() {
+    const newData = Object.assign({}, await this.readMetadata(), this.metadataDictionary)
+    await writeFile(this.metadataPath, JSON.stringify(newData))
+  }
+
+  getMetadataFor(item) {
+    const key = this.metadataDictionary[item.downloaderArg]
+    return this.metadataDictionary[key] || null
+  }
+
+  async processMetadata(item, reprocess = false, top = true) {
+    let counter = 0
+
+    if (isGroup(item)) {
+      const results = await Promise.all(item.items.map(x => this.processMetadata(x, reprocess, false)))
+      counter += results.reduce((acc, n) => acc + n, 0)
+    } else process: {
+      if (!reprocess && this.getMetadataFor(item)) {
+        break process
+      }
+
+      await this.throttleMetadata(async () => {
+        const filePath = await this.download(item)
+        const metadataReader = getMetadataReaderFor(filePath)
+        const data = await metadataReader(filePath)
+
+        this.metadataDictionary[item.downloaderArg] = filePath
+        this.metadataDictionary[filePath] = data
+      })
+
+      this.emit('processMetadata progress', this.throttleMetadata.queue.length)
+
+      counter++
+    }
+
+    if (top) {
+      await this.saveMetadata()
+    }
+
+    return counter
+  }
+
+  getRecordFor(item) {
+    return this.recordStore.getRecord(item)
+  }
+
   getDuration(item) {
     let noticedMissingMetadata = false
 
@@ -588,24 +658,9 @@ class Backend extends EventEmitter {
     return {seconds, string, noticedMissingMetadata, approxSymbol}
   }
 
-  async download(item) {
-    if (isGroup(item)) {
-      // TODO: Download all children (recursively), show a confirmation prompt
-      // if there are a lot of items (remember to flatten).
-      return
-    }
-
-    // Don't start downloading an item if we're already downloading it!
-    if (this.getRecordFor(item).downloading) {
-      return
-    }
-
-    const arg = item.downloaderArg
-    this.getRecordFor(item).downloading = true
-    try {
-      return await getDownloaderFor(arg)(arg)
-    } finally {
-      this.getRecordFor(item).downloading = false
+  async stopPlayingAll() {
+    for (const queuePlayer of this.queuePlayers) {
+      await queuePlayer.stopPlaying()
     }
   }
 }
diff --git a/telnet-server.js b/telnet-server.js
index 64304c6..b4422f5 100644
--- a/telnet-server.js
+++ b/telnet-server.js
@@ -32,6 +32,7 @@ class TelnetServer extends EventEmitter {
       appConfig: {
         canControlPlayback: false,
         canControlQueue: true,
+        canControlQueuePlayers: false,
         canProcessMetadata: false,
         canSuspend: false,
         showLeftPane: true,
diff --git a/todo.txt b/todo.txt
index 3470204..9e03156 100644
--- a/todo.txt
+++ b/todo.txt
@@ -403,3 +403,7 @@ TODO: Investigate why reveal() has distinct support for grouplikes as well as
       flattened children) after all. But if you could -- I think reveal should
       work the same for groups as it does for tracks, i.e. opening the parent
       and then selecting the item.
+
+TODO: Make the menubar work like context menus for keyboard selection, e.g.
+      pressing P should select 'Playback' (not bubble to the app element and
+      rewind to the previous track).
diff --git a/tui-lib b/tui-lib
-Subproject e4ae17895cd673bdc8a8a2a060b835b0492daeb
+Subproject e69d506fdc2d0238cb592256aca5353ecb29281
diff --git a/ui.js b/ui.js
index aafe064..de3ab98 100644
--- a/ui.js
+++ b/ui.js
@@ -84,6 +84,9 @@ const keyBindings = [
   ['isFocusLabels', 'L', {caseless: false}], // todo: better key? to let isToggleLoop be caseless again
   ['isSelectUp', telc.isShiftUp],
   ['isSelectDown', telc.isShiftDown],
+  ['isPreviousPlayer', telc.isMetaUp],
+  ['isNextPlayer', telc.isMetaDown],
+  ['isNewPlayer', keyBuf => keyBuf.equals(Buffer.from([0x1b, 0x6e]))],
 
   // Number pad
   ['isUp', '8'],
@@ -150,11 +153,12 @@ class AppElement extends FocusElement {
     this.isPartyHost = false
 
     this.bindListeners()
-    this.attachListeners()
+    this.initialAttachListeners()
 
     this.config = Object.assign({
       canControlPlayback: true,
       canControlQueue: true,
+      canControlQueuePlayers: true,
       canProcessMetadata: true,
       canSuspend: true,
       menubarColor: 4, // blue
@@ -198,7 +202,6 @@ class AppElement extends FocusElement {
 
     this.queueListingElement = new QueueListingElement(this)
     this.setupCommonGrouplikeListingEvents(this.queueListingElement)
-    this.queueListingElement.loadGrouplike(this.backend.queueGrouplike)
     this.paneRight.addChild(this.queueListingElement)
 
     this.queueLengthLabel = new Label('')
@@ -220,9 +223,9 @@ class AppElement extends FocusElement {
     this.playbackInfoElement = new PlaybackInfoElement()
     this.playbackPane.addChild(this.playbackInfoElement)
 
-    this.playbackInfoElement.on('seek back', () => this.backend.seekBack(5))
-    this.playbackInfoElement.on('seek ahead', () => this.backend.seekAhead(5))
-    this.playbackInfoElement.on('toggle pause', () => this.backend.togglePause())
+    this.playbackInfoElement.on('seek back', () => this.SQP.seekBack(5))
+    this.playbackInfoElement.on('seek ahead', () => this.SQP.seekAhead(5))
+    this.playbackInfoElement.on('toggle pause', () => this.SQP.togglePause())
 
     this.partyTop = new DisplayElement()
     this.partyBottom = new DisplayElement()
@@ -277,8 +280,8 @@ class AppElement extends FocusElement {
         this.config.canSuspend && {label: 'Suspend', action: () => this.suspend()}
       ]},
       {text: 'Playback', menuFn: () => {
-        const { playingTrack } = this.backend
-        const { items } = this.backend.queueGrouplike
+        const { playingTrack } = this.SQP
+        const { items } = this.SQP.queueGrouplike
         const curIndex = items.indexOf(playingTrack)
         const next = (curIndex >= 0) && items[curIndex + 1]
         const previous = (curIndex >= 0) && items[curIndex - 1]
@@ -291,13 +294,13 @@ class AppElement extends FocusElement {
           {element: this.pauseNextControl},
           {element: this.volumeSlider},
           {divider: true},
-          previous && {label: `Previous (${previous.name})`, action: () => this.backend.playPrevious(playingTrack)},
-          next && {label: `Next (${next.name})`, action: () => this.backend.playNext(playingTrack)},
+          previous && {label: `Previous (${previous.name})`, action: () => this.SQP.playPrevious(playingTrack)},
+          next && {label: `Next (${next.name})`, action: () => this.SQP.playNext(playingTrack)},
           next && {label: '- Play later', action: () => this.playLater(next)}
         ]
       }},
       {text: 'Queue', menuFn: () => {
-        const { items } = this.backend.queueGrouplike
+        const { items } = this.SQP.queueGrouplike
         const curIndex = items.indexOf(this.playingTrack)
 
         return [
@@ -310,49 +313,82 @@ class AppElement extends FocusElement {
     ])
 
     this.playingControl = new ToggleControl('Pause?', {
-      setValue: val => this.backend.setPause(val),
-      getValue: () => this.backend.player.isPaused,
+      setValue: val => this.SQP.setPause(val),
+      getValue: () => this.SQP.player.isPaused,
       getEnabled: () => this.config.canControlPlayback
     })
 
     this.loopingControl = new ToggleControl('Loop current track?', {
-      setValue: val => this.backend.setLoop(val),
-      getValue: () => this.backend.player.isLooping,
+      setValue: val => this.SQP.setLoop(val),
+      getValue: () => this.SQP.player.isLooping,
       getEnabled: () => this.config.canControlPlayback
     })
 
     this.pauseNextControl = new ToggleControl('Pause when this track ends?', {
-      setValue: val => this.backend.setPauseNextTrack(val),
-      getValue: () => this.backend.pauseNextTrack,
+      setValue: val => this.SQP.setPauseNextTrack(val),
+      getValue: () => this.SQP.pauseNextTrack,
       getEnabled: () => this.config.canControlPlayback
     })
 
     this.volumeSlider = new SliderElement('Volume', {
-      setValue: val => this.backend.setVolume(val),
-      getValue: () => this.backend.player.volume,
+      setValue: val => this.SQP.setVolume(val),
+      getValue: () => this.SQP.player.volume,
       getEnabled: () => this.config.canControlPlayback
     })
+
+    this.selectQueuePlayer(this.backend.queuePlayers[0])
   }
 
   bindListeners() {
-    this.handlePlaying = this.handlePlaying.bind(this)
-    this.handlePrintStatusLine = this.handlePrintStatusLine.bind(this)
-    this.handleProcessMetadataProgress = this.handleProcessMetadataProgress.bind(this)
-    this.handleQueueUpdated = this.handleQueueUpdated.bind(this)
+    for (const key of [
+      'handlePlaying',
+      'handleReceivedTimeData',
+      'handleProcessMetadataProgress',
+      'handleQueueUpdated',
+      'handleAddedQueuePlayer'
+    ]) {
+      this[key] = this[key].bind(this)
+    }
   }
 
-  attachListeners() {
-    this.backend.on('playing', this.handlePlaying)
-    this.backend.on('printStatusLine', this.handlePrintStatusLine)
-    this.backend.on('processMetadata progress', this.handleProcessMetadataProgress)
-    this.backend.on('queue updated', this.handleQueueUpdated)
+  initialAttachListeners() {
+    this.attachBackendListeners()
+    for (const queuePlayer of this.backend.queuePlayers) {
+      this.attachQueuePlayerListeners(queuePlayer)
+    }
   }
 
   removeListeners() {
-    this.backend.removeListener('playing', this.handlePlaying)
-    this.backend.removeListener('printStatusLine', this.handlePrintStatusLine)
+    this.removeBackendListeners()
+    for (const queuePlayer of this.backend.queuePlayers) {
+      this.removeQueuePlayerListeners(queuePlayer)
+    }
+  }
+
+  attachQueuePlayerListeners(queuePlayer) {
+    queuePlayer.on('received time data', this.handleReceivedTimeData)
+    queuePlayer.on('playing', this.handlePlaying)
+    queuePlayer.on('queue updated', this.handleQueueUpdated)
+  }
+
+  removeQueuePlayerListeners(queuePlayer) {
+    queuePlayer.removeListener('receivedTimeData', this.handleReceivedTimeData)
+    queuePlayer.removeListener('playing', this.handlePlaying)
+    queuePlayer.removeListener('queue updated', this.handleQueueUpdated)
+  }
+
+  attachBackendListeners() {
+    this.backend.on('processMetadata progress', this.handleProcessMetadataProgress)
+    this.backend.on('added queue player', this.handleAddedQueuePlayer)
+  }
+
+  removeBackendListeners() {
     this.backend.removeListener('processMetadata progress', this.handleProcessMetadataProgress)
-    this.backend.removeListener('queue updated', this.handleQueueUpdated)
+    this.backend.removeListener('added queue player', this.handleAddedQueuePlayer)
+  }
+
+  handleAddedQueuePlayer(queuePlayer) {
+    this.attachQueuePlayerListeners(queuePlayer)
   }
 
   handlePlaying(track, oldTrack) {
@@ -367,9 +403,11 @@ class AppElement extends FocusElement {
     this.updateQueueLengthLabel()
   }
 
-  handlePrintStatusLine(data) {
-    this.playbackInfoElement.updateProgress(data, this.backend.player)
-    this.updateQueueLengthLabel()
+  handleReceivedTimeData(data, queuePlayer) {
+    if (queuePlayer === this.SQP) {
+      this.playbackInfoElement.updateProgress(data, queuePlayer.player)
+      this.updateQueueLengthLabel()
+    }
   }
 
   handleProcessMetadataProgress(remaining) {
@@ -380,6 +418,45 @@ class AppElement extends FocusElement {
     this.queueListingElement.buildItems()
   }
 
+  selectQueuePlayer(queuePlayer) {
+    // You can use this.SQP as a shorthand to get this.
+    this.selectedQueuePlayer = queuePlayer
+
+    this.playbackInfoElement.updateTrack(queuePlayer.playingTrack)
+    if (queuePlayer.timeData) {
+      this.playbackInfoElement.updateProgress(queuePlayer.timeData, queuePlayer.player)
+    }
+
+    this.queueListingElement.loadGrouplike(queuePlayer.queueGrouplike)
+  }
+
+  selectNextQueuePlayer() {
+    const { queuePlayers } = this.backend
+    let index = queuePlayers.indexOf(this.SQP) + 1
+    if (index >= queuePlayers.length) {
+      index = 0
+    }
+    this.selectQueuePlayer(queuePlayers[index])
+  }
+
+  selectPreviousQueuePlayer() {
+    const { queuePlayers } = this.backend
+    let index = queuePlayers.indexOf(this.SQP) - 1
+    if (index <= -1) {
+      index = queuePlayers.length - 1
+    }
+    this.selectQueuePlayer(queuePlayers[index])
+  }
+
+  async addQueuePlayer() {
+    if (!this.config.canControlQueuePlayers) {
+      return false
+    }
+
+    const queuePlayer = await this.backend.addQueuePlayer()
+    this.selectQueuePlayer(queuePlayer)
+  }
+
   selected() {
     if (this.paneLeft.visible) {
       this.root.select(this.tabber)
@@ -397,7 +474,7 @@ class AppElement extends FocusElement {
     this.tabber.addTab(grouplikeListing)
     this.tabber.selectTab(grouplikeListing)
 
-    grouplikeListing.on('download', item => this.backend.download(item))
+    grouplikeListing.on('download', item => this.SQP.download(item))
     grouplikeListing.on('browse', item => grouplikeListing.loadGrouplike(item))
     grouplikeListing.on('queue', (item, opts) => this.handleQueueOptions(item, opts))
 
@@ -530,7 +607,7 @@ class AppElement extends FocusElement {
       return
     }
 
-    this.backend.play(item)
+    this.SQP.play(item)
   }
 
   unqueue(item) {
@@ -539,7 +616,7 @@ class AppElement extends FocusElement {
     }
 
     let focusItem = this.queueListingElement.currentItem
-    focusItem = this.backend.unqueue(item, focusItem)
+    focusItem = this.SQP.unqueue(item, focusItem)
 
     this.queueListingElement.buildItems()
     this.updateQueueLengthLabel()
@@ -554,7 +631,7 @@ class AppElement extends FocusElement {
       return
     }
 
-    this.backend.playSooner(item)
+    this.SQP.playSooner(item)
     // It may not have queued as soon as the user wants; in that case, they'll
     // want to queue it sooner again. Automatically reselect the track so that
     // this they don't have to navigate back to it by hand.
@@ -566,7 +643,7 @@ class AppElement extends FocusElement {
       return
     }
 
-    this.backend.playLater(item)
+    this.SQP.playLater(item)
     // Just for consistency with playSooner (you can press ^-L to quickly get
     // back to the current track).
     this.queueListingElement.selectAndShow(item)
@@ -577,7 +654,7 @@ class AppElement extends FocusElement {
       return
     }
 
-    this.backend.clearQueuePast(item)
+    this.SQP.clearQueuePast(item)
     this.queueListingElement.selectAndShow(item)
   }
 
@@ -586,7 +663,7 @@ class AppElement extends FocusElement {
       return
     }
 
-    this.backend.clearQueueUpTo(item)
+    this.SQP.clearQueueUpTo(item)
     this.queueListingElement.selectAndShow(item)
   }
 
@@ -740,7 +817,7 @@ class AppElement extends FocusElement {
 
   async shutdown() {
     if (this.config.stopPlayingUponQuit) {
-      await this.backend.stopPlaying()
+      await this.backend.stopPlayingAll()
     }
 
     this.emit('quitRequested')
@@ -878,23 +955,23 @@ class AppElement extends FocusElement {
       if ((telc.isLeft(keyBuf) || telc.isRight(keyBuf)) && this.menubar.isSelected) {
         return // le sigh
       } else if (input.isRight(keyBuf)) {
-        this.backend.seekAhead(10)
+        this.SQP.seekAhead(10)
       } else if (input.isLeft(keyBuf)) {
-        this.backend.seekBack(10)
+        this.SQP.seekBack(10)
       } else if (input.isTogglePause(keyBuf)) {
-        this.backend.togglePause()
+        this.SQP.togglePause()
       } else if (input.isToggleLoop(keyBuf)) {
-        this.backend.toggleLoop()
+        this.SQP.toggleLoop()
       } else if (input.isVolumeUp(keyBuf)) {
-        this.backend.volUp()
+        this.SQP.volUp()
       } else if (input.isVolumeDown(keyBuf)) {
-        this.backend.volDown()
+        this.SQP.volDown()
       } else if (input.isStop(keyBuf)) {
-        this.backend.stopPlaying()
+        this.SQP.stopPlaying()
       } else if (input.isSkipBack(keyBuf)) {
-        this.backend.playPrevious(this.backend.playingTrack, true)
+        this.SQP.playPrevious(this.SQP.playingTrack, true)
       } else if (input.isSkipAhead(keyBuf)) {
-        this.backend.playNext(this.backend.playingTrack, true)
+        this.SQP.playNext(this.SQP.playingTrack, true)
       }
     }
 
@@ -928,6 +1005,12 @@ class AppElement extends FocusElement {
       this.tabber.nextTab()
     } else if (this.tabber.isSelected && keyBuf.equals(Buffer.from(['T'.charCodeAt(0)]))) {
       this.tabber.previousTab()
+    } else if (input.isPreviousPlayer(keyBuf)) {
+      this.selectPreviousQueuePlayer()
+    } else if (input.isNextPlayer(keyBuf)) {
+      this.selectNextQueuePlayer()
+    } else if (input.isNewPlayer(keyBuf)) {
+      this.addQueuePlayer()
     } else {
       super.keyPressed(keyBuf)
     }
@@ -963,11 +1046,11 @@ class AppElement extends FocusElement {
   }
 
   shuffleQueue() {
-    this.backend.shuffleQueue()
+    this.SQP.shuffleQueue()
   }
 
   clearQueue() {
-    this.backend.clearQueue()
+    this.SQP.clearQueue()
     this.queueListingElement.selectNone()
     this.updateQueueLengthLabel()
 
@@ -980,12 +1063,16 @@ class AppElement extends FocusElement {
   // just directly moved from the old event listener on grouplikeListings for
   // 'queue'.
   handleQueueOptions(item, {where = 'end', order = 'normal', play = false, skip = false} = {}) {
+    if (!this.config.canControlQueue) {
+      return
+    }
+
     const passedItem = item
 
-    let { playingTrack } = this.backend
+    let { playingTrack } = this.SQP
 
     if (skip && playingTrack === item) {
-      this.backend.playNext(playingTrack)
+      this.SQP.playNext(playingTrack)
     }
 
     if (isGroup(item)) {
@@ -1011,7 +1098,7 @@ class AppElement extends FocusElement {
         afterItem = this.queueListingElement.currentItem
       }
 
-      this.backend.queue(item, afterItem, {
+      this.SQP.queue(item, afterItem, {
         movePlayingTrack: order === 'normal'
       })
 
@@ -1019,7 +1106,7 @@ class AppElement extends FocusElement {
         this.queueListingElement.selectAndShow(passedItem)
       }
     } else if (where.startsWith('distribute-')) {
-      this.backend.distributeQueue(item, {
+      this.SQP.distributeQueue(item, {
         how: where.slice('distribute-'.length)
       })
     }
@@ -1058,8 +1145,8 @@ class AppElement extends FocusElement {
   }
 
   updateQueueLengthLabel() {
-    const { playingTrack } = this.backend
-    const { items } = this.backend.queueGrouplike
+    const { playingTrack } = this.SQP
+    const { items } = this.SQP.queueGrouplike
 
     let trackRemainSec = 0
 
@@ -1091,7 +1178,7 @@ class AppElement extends FocusElement {
     const { duration } = getTimeStringsFromSec(0, totalRemainSec)
 
     this.queueLengthLabel.text = (playingTrack && items.includes(playingTrack)
-      ? `(${this.playSymbol} ${index} / ${items.length})`
+      ? `(${this.SQP.playSymbol} ${index} / ${items.length})`
       : `(${items.length})`)
 
     this.queueTimeLabel.text = `(${duration + approxSymbol})`
@@ -1102,18 +1189,13 @@ class AppElement extends FocusElement {
     this.queueTimeLabel.y = this.paneRight.contentH - 1
   }
 
-  get playSymbol() {
-    const { player, playingTrack } = this.backend
-    if (player && playingTrack) {
-      if (player.isPaused) {
-        return '⏸'
-      } else {
-        return '▶'
-      }
-    } else {
-      return '.'
-    }
+  get SQP() {
+    // Just a convenient shorthand.
+    return this.selectedQueuePlayer
   }
+
+  get selectedQueuePlayer() { return this.getDep('selectedQueuePlayer') }
+  set selectedQueuePlayer(v) { return this.setDep('selectedQueuePlayer', v) }
 }
 
 class GrouplikeListingElement extends Form {
@@ -1232,7 +1314,7 @@ class GrouplikeListingElement extends Form {
       this.form.selectAndShow(this.grouplike.items[this.grouplike.items.length - 1])
     } else if (keyBuf[0] === 12) { // ctrl-L
       if (this.grouplike.isTheQueue) {
-        this.form.selectAndShow(this.app.backend.playingTrack)
+        this.form.selectAndShow(this.app.SQP.playingTrack)
       } else {
         this.toggleExpandLabels()
       }
@@ -1437,7 +1519,9 @@ class GrouplikeListingElement extends Form {
       this.form.scrollSelectedElementIntoView()
     }
     this.jumpElement.visible = false
-    this.root.select(this)
+    if (this.jumpElement.isSelected) {
+      this.root.select(this)
+    }
     this.fixLayout()
   }
 
@@ -2345,7 +2429,7 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
       writable.write('G')
     } else if (record.downloading) {
       writable.write(braille[Math.floor(Date.now() / 250) % 6])
-    } else if (this.app.backend.playingTrack === this.item) {
+    } else if (this.app.SQP.playingTrack === this.item) {
       writable.write('\u25B6')
     } else {
       writable.write(' ')