« get me outta code hell

Multiple player UI interaction shenanigans - 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-23 05:56:22 -0300
committerFlorrie <towerofnix@gmail.com>2019-09-23 05:56:22 -0300
commit8d55de55298e00b9dc62f368c23e516f9941b5ba (patch)
treedc4ffd7f9eb9bc72a76ce8fad69da4f4ca14bf37
parent5a8f8eafa4e30d7a0febcf804253b6853caf36e7 (diff)
Multiple player UI interaction shenanigans
Please don't ever let me stay up until 29:57 again. Future me will thank
you in advance.
-rw-r--r--README.md5
-rw-r--r--todo.txt3
m---------tui-lib0
-rw-r--r--ui.js271
4 files changed, 230 insertions, 49 deletions
diff --git a/README.md b/README.md
index c15c273..4b3cb33 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,11 @@ You're also welcome to share any ideas, suggestions, and questions through there
 * t, T: switch between playlist tabs
 * Ctrl+T: open the current playlist in a new tab (so, clone the current tab)
 * Ctrl+W: close the current tab
+* Meta+c: create a new music player (for listening to multiple tracks at once, or swapping between two tracks without losing playback position)
+* Meta+Up/Down, Meta+p/n: select the previous/next music player (each player has its own independent queue, pause status, etc)
+* Meta+a, Meta+!: mark the selected music player so that any keyboard actions - seeking, pausing, etc - apply to it and any other marked players (if no player is marked, which is the default case, actions will apply to the selected music player)
+* Meta+x: delete the selected music player
+* |: focus the list of music players, if there are at least two music players
 * **In the main listing:**
   * Enter: if the selected item is a group, enter it; if it's a track, play it
   * Backspace: leave the current group (if in one)
diff --git a/todo.txt b/todo.txt
index 88ff268..8a86e5f 100644
--- a/todo.txt
+++ b/todo.txt
@@ -411,9 +411,10 @@ 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.)
+      (Done! Will probably be tweaked / expanded in the future.)
 
 TODO: Add a menu for controlling the multiple-players code through the menubar.
+      (Done! Oh gosh this was such a hack.)
 
 TODO: Investigate menubar UX - now that it uses KeyboardSelector, it's very
       comparable in interaction to ContextMenus. To match context menus,
diff --git a/tui-lib b/tui-lib
-Subproject e69d506fdc2d0238cb592256aca5353ecb29281
+Subproject faab576e85baf787c44a67640282ba146a874ef
diff --git a/ui.js b/ui.js
index 795c816..eafff01 100644
--- a/ui.js
+++ b/ui.js
@@ -73,6 +73,7 @@ const keyBindings = [
   ['isSkipAhead', 'n'],
   ['isFocusTabber', '['],
   ['isFocusQueue', ']'],
+  ['isFocusPlaybackInfo', '|'],
   ['isNextTab', 't', {caseless: false}],
   ['isPreviousTab', 'T', {caseless: false}],
   ['isDownload', 'd'],
@@ -91,6 +92,8 @@ const keyBindings = [
   ['isNextPlayer', [0x1b, 'n']],
   ['isNewPlayer', [0x1b, 'c']],
   ['isRemovePlayer', [0x1b, 'x']],
+  ['isActOnPlayer', [0x1b, 'a']],
+  ['isActOnPlayer', [0x1b, '!']],
 
   // Number pad
   ['isUp', '8'],
@@ -311,6 +314,19 @@ class AppElement extends FocusElement {
           items.length && {label: 'Shuffle', action: () => this.shuffleQueue()},
           items.length && {label: 'Clear', action: () => this.clearQueue()}
         ]
+      }},
+      {text: 'Multi', menuFn: () => {
+        const { queuePlayers } = this.backend
+        return [
+          {label: `(Multi-players - ${queuePlayers.length})`},
+          {divider: true},
+          ...queuePlayers.map((queuePlayer, index) => {
+            const PIE = new PlaybackInfoElement(queuePlayer, this)
+            PIE.displayMode = 'collapsed'
+            PIE.updateTrack()
+            return {element: PIE}
+          })
+        ]
       }}
     ])
 
@@ -341,6 +357,10 @@ class AppElement extends FocusElement {
     this.bindListeners()
     this.initialAttachListeners()
 
+    // Also handy to be bound to the app.
+    this.showContextMenu = this.showContextMenu.bind(this)
+
+    this.queuePlayersToActOn = []
     this.selectQueuePlayer(this.backend.queuePlayers[0])
   }
 
@@ -374,10 +394,9 @@ class AppElement extends FocusElement {
   }
 
   attachQueuePlayerListenersAndUI(queuePlayer) {
-    const PIE = new PlaybackInfoElement(queuePlayer)
+    const PIE = new PlaybackInfoElement(queuePlayer, this)
     this.playbackInfoElements.push(PIE)
     this.playbackForm.addInput(PIE)
-    PIE.updateIndex(this.playbackInfoElements.length)
     this.fixLayout()
 
     PIE.on('seek back', () => PIE.queuePlayer.seekBack(5))
@@ -395,11 +414,8 @@ class AppElement extends FocusElement {
       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--
-          }
+        if (this.playbackForm.curIndex > oldIndex) {
+          this.playbackForm.curIndex--
         }
         PIEs.splice(oldIndex, 1)
         this.playbackForm.removeInput(PIE)
@@ -411,6 +427,11 @@ class AppElement extends FocusElement {
       }
     }
 
+    const index = this.queuePlayersToActOn.indexOf(queuePlayer)
+    if (index >= 0) {
+      this.queuePlayersToActOn.splice(index, 1)
+    }
+
     queuePlayer.removeListener('receivedTimeData', this.handleReceivedTimeData)
     queuePlayer.removeListener('playing', this.handlePlaying)
     queuePlayer.removeListener('queue updated', this.handleQueueUpdated)
@@ -473,10 +494,6 @@ class AppElement extends FocusElement {
     // You can use this.SQP as a shorthand to get this.
     this.selectedQueuePlayer = queuePlayer
 
-    for (const PIE of this.playbackInfoElements) {
-      PIE.updateSQP(queuePlayer)
-    }
-
     this.queueListingElement.loadGrouplike(queuePlayer.queueGrouplike)
 
     this.playbackForm.curIndex = this.playbackForm.inputs
@@ -519,6 +536,19 @@ class AppElement extends FocusElement {
     this.backend.removeQueuePlayer(queuePlayer)
   }
 
+  toggleActOnQueuePlayer(queuePlayer) {
+    const index = this.queuePlayersToActOn.indexOf(queuePlayer)
+    if (index >= 0) {
+      this.queuePlayersToActOn.splice(index, 1)
+    } else {
+      this.queuePlayersToActOn.push(queuePlayer)
+    }
+
+    for (const PIE of this.playbackInfoElements) {
+      PIE.fixLayout()
+    }
+  }
+
   getPlaybackInfoElementForQueuePlayer(queuePlayer) {
     return this.playbackInfoElements
       .find(el => el.queuePlayer === queuePlayer)
@@ -738,6 +768,58 @@ class AppElement extends FocusElement {
     this.markGrouplike.items.splice(0)
   }
 
+  pauseAll() {
+    if (!this.config.canControlPlayback) {
+      return
+    }
+
+    for (const queuePlayer of this.backend.queuePlayers) {
+      queuePlayer.setPause(true)
+    }
+  }
+
+  resumeAll() {
+    if (!this.config.canControlPlayback) {
+      return
+    }
+
+    for (const queuePlayer of this.backend.queuePlayers) {
+      queuePlayer.setPause(false)
+    }
+  }
+
+  set actOnAllPlayers(val) {
+    if (val) {
+      this.queuePlayersToActOn = this.backend.queuePlayers.slice()
+    } else {
+      this.queuePlayersToActOn = []
+    }
+  }
+
+  get actOnAllPlayers() {
+    return this.queuePlayersToActOn.length === this.backend.queuePlayers.length
+  }
+
+  willActOnQueuePlayer(queuePlayer) {
+    if (this.queuePlayersToActOn.length) {
+      if (this.queuePlayersToActOn.includes(queuePlayer)) {
+        return 'marked'
+      }
+    } else if (queuePlayer === this.SQP) {
+      return '=SQP'
+    }
+  }
+
+  actOnQueuePlayers(fn) {
+    if (this.queuePlayersToActOn.length) {
+      for (const queuePlayer of this.queuePlayersToActOn) {
+        fn(queuePlayer)
+      }
+    } else {
+      fn(this.SQP)
+    }
+  }
+
   showMenuForItemElement(el, listing) {
     const emitControls = play => () => {
       this.handleQueueOptions(item, {
@@ -1031,23 +1113,23 @@ class AppElement extends FocusElement {
       if ((telc.isLeft(keyBuf) || telc.isRight(keyBuf)) && this.menubar.isSelected) {
         return // le sigh
       } else if (input.isRight(keyBuf)) {
-        this.SQP.seekAhead(10)
+        this.actOnQueuePlayers(qp => qp.seekAhead(10))
       } else if (input.isLeft(keyBuf)) {
-        this.SQP.seekBack(10)
+        this.actOnQueuePlayers(qp => qp.seekBack(10))
       } else if (input.isTogglePause(keyBuf)) {
-        this.SQP.togglePause()
+        this.actOnQueuePlayers(qp => qp.togglePause())
       } else if (input.isToggleLoop(keyBuf)) {
-        this.SQP.toggleLoop()
+        this.actOnQueuePlayers(qp => qp.toggleLoop())
       } else if (input.isVolumeUp(keyBuf)) {
-        this.SQP.volUp()
+        this.actOnQueuePlayers(qp => qp.volUp())
       } else if (input.isVolumeDown(keyBuf)) {
-        this.SQP.volDown()
+        this.actOnQueuePlayers(qp => qp.volDown())
       } else if (input.isStop(keyBuf)) {
-        this.SQP.stopPlaying()
+        this.actOnQueuePlayers(qp => qp.stopPlaying())
       } else if (input.isSkipBack(keyBuf)) {
-        this.SQP.playPrevious(this.SQP.playingTrack, true)
+        this.actOnQueuePlayers(qp => qp.playPrevious(qp.playingTrack, true))
       } else if (input.isSkipAhead(keyBuf)) {
-        this.SQP.playNext(this.SQP.playingTrack, true)
+        this.actOnQueuePlayers(qp => qp.playNext(qp.playingTrack, true))
       }
     }
 
@@ -1055,6 +1137,8 @@ class AppElement extends FocusElement {
       this.root.select(this.tabber)
     } else if (input.isFocusQueue(keyBuf) && this.queueListingElement.selectable) {
       this.root.select(this.queueListingElement)
+    } else if (input.isFocusPlaybackInfo(keyBuf) && this.backend.queuePlayers.length > 1) {
+      this.root.select(this.playbackForm)
     } else if (input.isFocusMenubar(keyBuf)) {
       if (this.menubar.isSelected) {
         this.menubar.restoreSelection()
@@ -1089,6 +1173,8 @@ class AppElement extends FocusElement {
       this.addQueuePlayer()
     } else if (input.isRemovePlayer(keyBuf)) {
       this.removeQueuePlayer(this.SQP)
+    } else if (input.isActOnPlayer(keyBuf)) {
+      this.toggleActOnQueuePlayer(this.SQP)
     } else {
       super.keyPressed(keyBuf)
     }
@@ -2685,11 +2771,13 @@ class QueueListingForm extends GrouplikeListingForm {
   }
 }
 
-class PlaybackInfoElement extends DisplayElement {
-  constructor(queuePlayer) {
+class PlaybackInfoElement extends FocusElement {
+  constructor(queuePlayer, app) {
     super()
 
     this.queuePlayer = queuePlayer
+    this.app = app
+
     this.displayMode = 'expanded'
     this.timeData = {}
 
@@ -2757,28 +2845,41 @@ class PlaybackInfoElement extends DisplayElement {
 
   fixLayoutCollapsed() {
     if (this.parent) {
-      this.w = this.parent.contentW
+      this.w = Math.max(30, this.parent.contentW)
     }
     this.h = 1
 
     this.queuePlayerIndexLabel.visible = true
     this.downloadLabel.visible = false
 
-    this.queuePlayerIndexLabel.text = (this.queuePlayerSelected
-      ? `<${this.queuePlayerIndex}>`
-      : ` ${this.queuePlayerIndex} `)
+    const why = this.app.willActOnQueuePlayer(this.queuePlayer)
+    const index = this.app.backend.queuePlayers.indexOf(this.queuePlayer)
+    const msg = (why ? '!' : ' ') + index
+
+    this.queuePlayerIndexLabel.text = (this.app.SQP === this.queuePlayer
+      ? `<${msg}>`
+      : ` ${msg} `)
 
-    this.queuePlayerIndexLabel.x = 2
+    if (why === 'marked') {
+      this.queuePlayerIndexLabel.textAttributes = [ansi.A_BRIGHT]
+    } else {
+      this.queuePlayerIndexLabel.textAttributes = []
+    }
+
+    this.queuePlayerIndexLabel.x = 1
     this.queuePlayerIndexLabel.y = 0
 
-    this.trackNameLabel.x = this.queuePlayerIndexLabel.right + 2
+    this.trackNameLabel.x = this.queuePlayerIndexLabel.right + 1
     this.trackNameLabel.y = 0
 
     this.progressBarLabel.y = 0
     this.progressBarLabel.x = 0
 
-    this.progressTextLabel.x = this.trackNameLabel.right + 2
+    this.progressTextLabel.x = this.contentW - this.progressTextLabel.w - 1
     this.progressTextLabel.y = 0
+
+    this.refreshTrackText(this.progressTextLabel.x - 2 - this.trackNameLabel.x)
+    this.refreshProgressText()
   }
 
   clicked(button) {
@@ -2787,10 +2888,65 @@ class PlaybackInfoElement extends DisplayElement {
     } else if (button === 'scroll-down') {
       this.emit('seek ahead')
     } else if (button === 'left') {
-      this.emit('toggle pause')
+      if (this.displayMode === 'expanded') {
+        this.emit('toggle pause')
+      } else if (this.isSelected) {
+        this.showMenu()
+      } else {
+        this.root.select(this)
+      }
+    }
+  }
+
+  keyPressed(keyBuf) {
+    if (input.isSelect(keyBuf)) {
+      this.showMenu()
+      return false
     }
   }
 
+  showMenu() {
+    const fn = this.showContextMenu || this.app.showContextMenu
+    fn({
+      x: this.absLeft,
+      y: this.absTop + 1,
+      items: [
+        {
+          label: 'Select',
+          action: () => {
+            this.app.selectQueuePlayer(this.queuePlayer)
+            this.parent.fixLayout()
+          }
+        },
+        {
+          label: (this.app.willActOnQueuePlayer(this.queuePlayer) === 'marked'
+            ? 'Remove from multiple-player selection'
+            : 'Add to multiple-player selection'),
+          action: () => {
+            this.app.toggleActOnQueuePlayer(this.queuePlayer)
+            this.parent.fixLayout()
+          }
+        },
+        this.app.backend.queuePlayers.length > 1 && {
+          label: 'Delete',
+          action: () => {
+            const { parent } = this
+            this.app.removeQueuePlayer(this.queuePlayer)
+            if (parent) {
+              parent.removeInput(this)
+              parent.fixLayout()
+              // uhhh for some reason this selects the app instead of the form
+              // for all the PIEs? not sure why but it probably has to do with
+              // context menu shenanigans. it's currently 29:50 and i am really
+              // definitely not going to try to figure that out right now!
+              parent.root.select(parent)
+            }
+          }
+        }
+      ]
+    })
+  }
+
   refreshProgressText() {
     const { player, timeData } = this.queuePlayer
     if (!timeData) {
@@ -2815,43 +2971,62 @@ class PlaybackInfoElement extends DisplayElement {
     }
   }
 
-  updateProgress() {
-    this.refreshProgressText()
-    this.fixLayout()
-  }
-
-  updateTrack() {
+  refreshTrackText(maxNameWidth = Infinity) {
     const { playingTrack } = this.queuePlayer
     if (playingTrack) {
       this.currentTrack = playingTrack
-      this.trackNameLabel.text = playingTrack.name
+      const { name } = playingTrack
+      if (ansi.measureColumns(name) > maxNameWidth) {
+        this.trackNameLabel.text = ansi.trimToColumns(name, maxNameWidth) + unic.ELLIPSIS
+      } else {
+        this.trackNameLabel.text = playingTrack.name
+      }
       this.progressBarLabel.text = ''
       this.progressTextLabel.text = '(Starting..)'
       this.timeData = {}
-      this.fixLayout()
     } else {
-      this.clearInfo()
+      this.clearInfoText()
     }
   }
 
-  updateSQP(SQP) {
-    this.queuePlayerSelected = SQP === this.queuePlayer
-  }
-
-  updateIndex(index) {
-    this.queuePlayerIndex = index
-  }
-
-  clearInfo() {
+  clearInfoText() {
     this.currentTrack = null
     this.progressBarLabel.text = ''
     this.progressTextLabel.text = ''
     this.trackNameLabel.text = ''
     this.downloadLabel.text = ''
     this.timeData = {}
+  }
+
+  updateProgress() {
+    this.refreshProgressText()
     this.fixLayout()
   }
 
+  updateTrack() {
+    this.refreshTrackText()
+    this.fixLayout()
+  }
+
+  clearInfo() {
+    this.clearInfoText()
+    this.fixLayout()
+  }
+
+  drawTo(writable) {
+    if (this.isSelected) {
+      this.progressBarLabel.textAttributes = [ansi.A_INVERT]
+    } else {
+      this.progressBarLabel.textAttributes = []
+    }
+
+    if (this.isSelected) {
+      writable.write(ansi.invert())
+      writable.write(ansi.moveCursor(this.absTop, this.absLeft))
+      writable.write(' '.repeat(this.w))
+    }
+  }
+
   get curSecTotal() { return this.getDep('curSecTotal') }
   set curSecTotal(v) { return this.setDep('curSecTotal', v) }
   get lenSecTotal() { return this.getDep('lenSecTotal') }