From 8d55de55298e00b9dc62f368c23e516f9941b5ba Mon Sep 17 00:00:00 2001 From: Florrie Date: Mon, 23 Sep 2019 05:56:22 -0300 Subject: Multiple player UI interaction shenanigans Please don't ever let me stay up until 29:57 again. Future me will thank you in advance. --- README.md | 5 ++ todo.txt | 3 +- tui-lib | 2 +- ui.js | 271 +++++++++++++++++++++++++++++++++++++++++++++++++++----------- 4 files changed, 231 insertions(+), 50 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 index e69d506..faab576 160000 --- a/tui-lib +++ b/tui-lib @@ -1 +1 @@ -Subproject commit e69d506fdc2d0238cb592256aca5353ecb292816 +Subproject commit faab576e85baf787c44a67640282ba146a874ef5 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') } -- cgit 1.3.0-6-gf8a5