« get me outta code hell

Basic multiple player UI - 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-22 12:43:34 -0300
committerFlorrie <towerofnix@gmail.com>2019-09-22 12:43:34 -0300
commit88dd9466e513bb562e242d152765ec204e2e2b2d (patch)
treec6affeba6dac72ee7a15d48552944b4f5a7cc2fd
parent571026560ec61e26c4498f3d7e9a982c9b5aa68e (diff)
Basic multiple player UI
Currently uses meta+(c, x, n, p, up, down) keys as the only interaction
method, but that'll change soon!
-rw-r--r--backend.js11
-rw-r--r--client.js1
-rw-r--r--todo.txt6
-rw-r--r--ui.js248
4 files changed, 218 insertions, 48 deletions
diff --git a/backend.js b/backend.js
index e8601c5..d3e0db7 100644
--- a/backend.js
+++ b/backend.js
@@ -374,7 +374,7 @@ class QueuePlayer extends EventEmitter {
       }
 
       this.playingTrack = item
-      this.emit('playing', this.playingTrack, oldTrack)
+      this.emit('playing', this.playingTrack, oldTrack, this)
 
       await this.player.kill()
       if (this.playedTrackToEnd) {
@@ -455,7 +455,7 @@ class QueuePlayer extends EventEmitter {
       const oldTrack = this.playingTrack
       this.playingTrack = null
       this.timeData = null
-      this.emit('playing', null, oldTrack)
+      this.emit('playing', null, oldTrack, this)
     }
   }
 
@@ -574,6 +574,13 @@ class Backend extends EventEmitter {
     return queuePlayer
   }
 
+  removeQueuePlayer(queuePlayer) {
+    if (this.queuePlayers.length > 1) {
+      this.queuePlayers.splice(this.queuePlayers.indexOf(queuePlayer), 1)
+      this.emit('removed queue player', queuePlayer)
+    }
+  }
+
   async readMetadata() {
     try {
       return JSON.parse(await readFile(this.metadataPath))
diff --git a/client.js b/client.js
index c591a00..a48ca3f 100644
--- a/client.js
+++ b/client.js
@@ -77,7 +77,6 @@ const setupClient = async ({backend, writable, interfacer, appConfig}) => {
 
   // Load up initial state
   appElement.queueListingElement.buildItems()
-  appElement.playbackInfoElement.updateTrack(backend.playingTrack)
 
   return {appElement, cleanTerminal, dirtyTerminal, flushable, root}
 }
diff --git a/todo.txt b/todo.txt
index fc57067..88ff268 100644
--- a/todo.txt
+++ b/todo.txt
@@ -411,5 +411,11 @@ TODO: Make the menubar work like context menus for keyboard selection, e.g.
 
 TODO: Implement a UI in the playback info pane that shows when multiple players
       exist at once. Make sure it's mouse-interactive, too!
+      (WIP! Display implemented, but it's not interactive yet.)
 
 TODO: Add a menu for controlling the multiple-players code through the menubar.
+
+TODO: Investigate menubar UX - now that it uses KeyboardSelector, it's very
+      comparable in interaction to ContextMenus. To match context menus,
+      should we make its selection index reset to zero (i.e. the 'mtui' option)
+      whenever it's selected (via the keyboard)?
diff --git a/ui.js b/ui.js
index d70bc31..795c816 100644
--- a/ui.js
+++ b/ui.js
@@ -84,9 +84,13 @@ const keyBindings = [
   ['isFocusLabels', 'L', {caseless: false}], // todo: better key? to let isToggleLoop be caseless again
   ['isSelectUp', telc.isShiftUp],
   ['isSelectDown', telc.isShiftDown],
+
   ['isPreviousPlayer', telc.isMetaUp],
+  ['isPreviousPlayer', [0x1b, 'p']],
   ['isNextPlayer', telc.isMetaDown],
-  ['isNewPlayer', keyBuf => keyBuf.equals(Buffer.from([0x1b, 0x6e]))],
+  ['isNextPlayer', [0x1b, 'n']],
+  ['isNewPlayer', [0x1b, 'c']],
+  ['isRemovePlayer', [0x1b, 'x']],
 
   // Number pad
   ['isUp', '8'],
@@ -127,6 +131,9 @@ const addKey = (prop, keyOrFunc, {caseless = true} = {}) => {
     } else {
       newFunc = input => input.toString() === key
     }
+  } else if (Array.isArray(keyOrFunc)) {
+    const buf = Buffer.from(keyOrFunc.map(k => typeof k === 'string' ? k.charCodeAt(0) : k))
+    newFunc = keyBuf => keyBuf.equals(buf)
   }
   input[prop] = keyBuf => newFunc(keyBuf) || oldFunc(keyBuf)
 }
@@ -152,9 +159,6 @@ class AppElement extends FocusElement {
     this.telnetServer = null
     this.isPartyHost = false
 
-    this.bindListeners()
-    this.initialAttachListeners()
-
     this.config = Object.assign({
       canControlPlayback: true,
       canControlQueue: true,
@@ -220,12 +224,10 @@ class AppElement extends FocusElement {
     this.playbackPane = new Pane()
     this.addChild(this.playbackPane)
 
-    this.playbackInfoElement = new PlaybackInfoElement()
-    this.playbackPane.addChild(this.playbackInfoElement)
+    this.playbackForm = new ListScrollForm()
+    this.playbackPane.addChild(this.playbackForm)
 
-    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.playbackInfoElements = []
 
     this.partyTop = new DisplayElement()
     this.partyBottom = new DisplayElement()
@@ -336,6 +338,9 @@ class AppElement extends FocusElement {
       getEnabled: () => this.config.canControlPlayback
     })
 
+    this.bindListeners()
+    this.initialAttachListeners()
+
     this.selectQueuePlayer(this.backend.queuePlayers[0])
   }
 
@@ -345,7 +350,8 @@ class AppElement extends FocusElement {
       'handleReceivedTimeData',
       'handleProcessMetadataProgress',
       'handleQueueUpdated',
-      'handleAddedQueuePlayer'
+      'handleAddedQueuePlayer',
+      'handleRemovedQueuePlayer'
     ]) {
       this[key] = this[key].bind(this)
     }
@@ -354,24 +360,57 @@ class AppElement extends FocusElement {
   initialAttachListeners() {
     this.attachBackendListeners()
     for (const queuePlayer of this.backend.queuePlayers) {
-      this.attachQueuePlayerListeners(queuePlayer)
+      this.attachQueuePlayerListenersAndUI(queuePlayer)
     }
   }
 
   removeListeners() {
     this.removeBackendListeners()
     for (const queuePlayer of this.backend.queuePlayers) {
-      this.removeQueuePlayerListeners(queuePlayer)
+      // Don't update the UI - removeListeners is only called just before the
+      // AppElement is done being used.
+      this.removeQueuePlayerListenersAndUI(queuePlayer, false)
     }
   }
 
-  attachQueuePlayerListeners(queuePlayer) {
+  attachQueuePlayerListenersAndUI(queuePlayer) {
+    const PIE = new PlaybackInfoElement(queuePlayer)
+    this.playbackInfoElements.push(PIE)
+    this.playbackForm.addInput(PIE)
+    PIE.updateIndex(this.playbackInfoElements.length)
+    this.fixLayout()
+
+    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', this.handlePlaying)
     queuePlayer.on('queue updated', this.handleQueueUpdated)
   }
 
-  removeQueuePlayerListeners(queuePlayer) {
+  removeQueuePlayerListenersAndUI(queuePlayer, updateUI = true) {
+    if (updateUI) {
+      const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
+      if (PIE) {
+        const PIEs = this.playbackInfoElements
+        const oldIndex = PIEs.indexOf(PIE)
+        for (let i = oldIndex + 1; i < PIEs.length; i++) {
+          PIEs[i].updateIndex(i)
+          if (this.SQP === PIEs[i].queuePlayer) {
+            this.playbackForm.curIndex--
+          }
+        }
+        PIEs.splice(oldIndex, 1)
+        this.playbackForm.removeInput(PIE)
+        if (this.SQP === queuePlayer) {
+          const { queuePlayer } = PIEs[Math.min(oldIndex, PIEs.length - 1)]
+          this.selectQueuePlayer(queuePlayer)
+        }
+        this.fixLayout()
+      }
+    }
+
     queuePlayer.removeListener('receivedTimeData', this.handleReceivedTimeData)
     queuePlayer.removeListener('playing', this.handlePlaying)
     queuePlayer.removeListener('queue updated', this.handleQueueUpdated)
@@ -380,32 +419,44 @@ class AppElement extends FocusElement {
   attachBackendListeners() {
     this.backend.on('processMetadata progress', this.handleProcessMetadataProgress)
     this.backend.on('added queue player', this.handleAddedQueuePlayer)
+    this.backend.on('removed queue player', this.handleRemovedQueuePlayer)
   }
 
   removeBackendListeners() {
     this.backend.removeListener('processMetadata progress', this.handleProcessMetadataProgress)
     this.backend.removeListener('added queue player', this.handleAddedQueuePlayer)
+    this.backend.removeListener('removed queue player', this.handleRemovedQueuePlayer)
   }
 
   handleAddedQueuePlayer(queuePlayer) {
-    this.attachQueuePlayerListeners(queuePlayer)
+    this.attachQueuePlayerListenersAndUI(queuePlayer)
+  }
+
+  handleRemovedQueuePlayer(queuePlayer) {
+    this.removeQueuePlayerListenersAndUI(queuePlayer)
   }
 
-  handlePlaying(track, oldTrack) {
-    if (track) {
-      this.playbackInfoElement.updateTrack(track)
-      if (this.queueListingElement.currentItem === oldTrack) {
+  handlePlaying(track, oldTrack, queuePlayer) {
+    const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
+    if (PIE) {
+      PIE.updateTrack()
+    }
+
+    if (queuePlayer === this.SQP) {
+      this.updateQueueLengthLabel()
+      if (track && this.queueListingElement.currentItem === oldTrack) {
         this.queueListingElement.selectAndShow(track)
       }
-    } else {
-      this.playbackInfoElement.clearInfo()
     }
-    this.updateQueueLengthLabel()
   }
 
   handleReceivedTimeData(data, queuePlayer) {
+    const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
+    if (PIE) {
+      PIE.updateProgress(data)
+    }
+
     if (queuePlayer === this.SQP) {
-      this.playbackInfoElement.updateProgress(data, queuePlayer.player)
       this.updateQueueLengthLabel()
     }
   }
@@ -422,12 +473,15 @@ class AppElement extends FocusElement {
     // 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)
+    for (const PIE of this.playbackInfoElements) {
+      PIE.updateSQP(queuePlayer)
     }
 
     this.queueListingElement.loadGrouplike(queuePlayer.queueGrouplike)
+
+    this.playbackForm.curIndex = this.playbackForm.inputs
+      .findIndex(el => el.queuePlayer === queuePlayer)
+    this.playbackForm.scrollSelectedElementIntoView()
   }
 
   selectNextQueuePlayer() {
@@ -457,6 +511,19 @@ class AppElement extends FocusElement {
     this.selectQueuePlayer(queuePlayer)
   }
 
+  removeQueuePlayer(queuePlayer) {
+    if (!this.config.canControlQueuePlayers) {
+      return false
+    }
+
+    this.backend.removeQueuePlayer(queuePlayer)
+  }
+
+  getPlaybackInfoElementForQueuePlayer(queuePlayer) {
+    return this.playbackInfoElements
+      .find(el => el.queuePlayer === queuePlayer)
+  }
+
   selected() {
     if (this.paneLeft.visible) {
       this.root.select(this.tabber)
@@ -830,8 +897,9 @@ class AppElement extends FocusElement {
   }
 
   fixLayout() {
-    this.w = this.parent.contentW
-    this.h = this.parent.contentH
+    if (this.parent) {
+      this.fillParent()
+    }
 
     this.menubar.fixLayout()
 
@@ -852,6 +920,16 @@ class AppElement extends FocusElement {
     this.playbackPane.y = topY - this.playbackPane.h
     topY = this.playbackPane.top
 
+    for (const PIE of this.playbackInfoElements) {
+      if (this.playbackInfoElements.length === 1) {
+        PIE.displayMode = 'expanded'
+      } else {
+        PIE.displayMode = 'collapsed'
+      }
+    }
+    this.playbackForm.fillParent()
+    this.playbackForm.fixLayout()
+
     let bottomY = 1
 
     if (this.partyTop.visible) {
@@ -896,8 +974,6 @@ class AppElement extends FocusElement {
 
     this.updateQueueLengthLabel()
 
-    this.playbackInfoElement.fillParent()
-
     this.menuLayer.fillParent()
   }
 
@@ -1011,6 +1087,8 @@ class AppElement extends FocusElement {
       this.selectNextQueuePlayer()
     } else if (input.isNewPlayer(keyBuf)) {
       this.addQueuePlayer()
+    } else if (input.isRemovePlayer(keyBuf)) {
+      this.removeQueuePlayer(this.SQP)
     } else {
       super.keyPressed(keyBuf)
     }
@@ -1145,13 +1223,18 @@ class AppElement extends FocusElement {
   }
 
   updateQueueLengthLabel() {
-    const { playingTrack } = this.SQP
+    if (!this.SQP) {
+      this.queueTimeLabel.text = ''
+      return
+    }
+
+    const { playingTrack, timeData } = this.SQP
     const { items } = this.SQP.queueGrouplike
 
     let trackRemainSec = 0
 
-    if (playingTrack) {
-      const { curSecTotal = 0, lenSecTotal = 0 } = this.playbackInfoElement.timeData
+    if (timeData) {
+      const { curSecTotal = 0, lenSecTotal = 0 } = timeData
       trackRemainSec = lenSecTotal - curSecTotal
     }
 
@@ -1167,7 +1250,7 @@ class AppElement extends FocusElement {
       // If it's NOT counted by the playback info element's time data yet,
       // we skip this - the current track is counted as "ahead" and its
       // duration will be tallied like the rest of the "ahead" tracks.
-      if (Object.keys(this.playbackInfoElement.timeData).length) {
+      if (timeData) {
         index++
       }
     }
@@ -2603,11 +2686,16 @@ class QueueListingForm extends GrouplikeListingForm {
 }
 
 class PlaybackInfoElement extends DisplayElement {
-  constructor() {
+  constructor(queuePlayer) {
     super()
 
+    this.queuePlayer = queuePlayer
+    this.displayMode = 'expanded'
     this.timeData = {}
 
+    this.queuePlayerIndex = 0
+    this.queuePlayerSelected = false
+
     this.progressBarLabel = new Label('')
     this.addChild(this.progressBarLabel)
 
@@ -2619,10 +2707,30 @@ class PlaybackInfoElement extends DisplayElement {
 
     this.downloadLabel = new Label('')
     this.addChild(this.downloadLabel)
+
+    this.queuePlayerIndexLabel = new Label('')
+    this.addChild(this.queuePlayerIndexLabel)
+
+    this.updateTrack()
+    this.updateProgress()
   }
 
   fixLayout() {
-    const centerX = el => el.x = Math.round((this.w - el.w) / 2)
+    this.refreshProgressText()
+    if (this.displayMode === 'expanded') {
+      this.fixLayoutExpanded()
+    } else if (this.displayMode === 'collapsed') {
+      this.fixLayoutCollapsed()
+    }
+  }
+
+  fixLayoutExpanded() {
+    if (this.parent) {
+      this.fillParent()
+    }
+
+    this.queuePlayerIndexLabel.visible = false
+    this.downloadLabel.visible = true
 
     this.trackNameLabel.y = 0
     this.progressBarLabel.y = 1
@@ -2638,9 +2746,39 @@ class PlaybackInfoElement extends DisplayElement {
       this.downloadLabel.text = `(From: ${dlText})`
     }
 
-    centerX(this.progressTextLabel)
-    centerX(this.trackNameLabel)
-    centerX(this.downloadLabel)
+    for (const el of [
+      this.progressTextLabel,
+      this.trackNameLabel,
+      this.downloadLabel
+    ]) {
+      el.x = Math.round((this.w - el.w) / 2)
+    }
+  }
+
+  fixLayoutCollapsed() {
+    if (this.parent) {
+      this.w = this.parent.contentW
+    }
+    this.h = 1
+
+    this.queuePlayerIndexLabel.visible = true
+    this.downloadLabel.visible = false
+
+    this.queuePlayerIndexLabel.text = (this.queuePlayerSelected
+      ? `<${this.queuePlayerIndex}>`
+      : ` ${this.queuePlayerIndex} `)
+
+    this.queuePlayerIndexLabel.x = 2
+    this.queuePlayerIndexLabel.y = 0
+
+    this.trackNameLabel.x = this.queuePlayerIndexLabel.right + 2
+    this.trackNameLabel.y = 0
+
+    this.progressBarLabel.y = 0
+    this.progressBarLabel.x = 0
+
+    this.progressTextLabel.x = this.trackNameLabel.right + 2
+    this.progressTextLabel.y = 0
   }
 
   clicked(button) {
@@ -2653,8 +2791,13 @@ class PlaybackInfoElement extends DisplayElement {
     }
   }
 
-  updateProgress(timeData, player) {
-    const {timeDone, duration, lenSecTotal, curSecTotal} = timeData
+  refreshProgressText() {
+    const { player, timeData } = this.queuePlayer
+    if (!timeData) {
+      return
+    }
+
+    const { timeDone, duration, lenSecTotal, curSecTotal } = timeData
     this.timeData = timeData
     this.curSecTotal = curSecTotal
     this.lenSecTotal = lenSecTotal
@@ -2670,13 +2813,18 @@ class PlaybackInfoElement extends DisplayElement {
     if (player.volume !== 100) {
       this.progressTextLabel.text += ` [Volume: ${Math.round(player.volume)}%]`
     }
+  }
+
+  updateProgress() {
+    this.refreshProgressText()
     this.fixLayout()
   }
 
-  updateTrack(track) {
-    if (track) {
-      this.currentTrack = track
-      this.trackNameLabel.text = track.name
+  updateTrack() {
+    const { playingTrack } = this.queuePlayer
+    if (playingTrack) {
+      this.currentTrack = playingTrack
+      this.trackNameLabel.text = playingTrack.name
       this.progressBarLabel.text = ''
       this.progressTextLabel.text = '(Starting..)'
       this.timeData = {}
@@ -2686,6 +2834,14 @@ class PlaybackInfoElement extends DisplayElement {
     }
   }
 
+  updateSQP(SQP) {
+    this.queuePlayerSelected = SQP === this.queuePlayer
+  }
+
+  updateIndex(index) {
+    this.queuePlayerIndex = index
+  }
+
   clearInfo() {
     this.currentTrack = null
     this.progressBarLabel.text = ''
@@ -2706,6 +2862,8 @@ class PlaybackInfoElement extends DisplayElement {
   set isLooping(v) { return this.setDep('isLooping', v) }
   get isPaused() { return this.getDep('isPaused') }
   set isPaused(v) { return this.setDep('isPaused', v) }
+  get currentTrack() { return this.getDep('currentTrack') }
+  set currentTrack(v) { return this.setDep('currentTrack', v) }
 }
 
 class OpenPlaylistDialog extends Dialog {