From 88dd9466e513bb562e242d152765ec204e2e2b2d Mon Sep 17 00:00:00 2001 From: Florrie Date: Sun, 22 Sep 2019 12:43:34 -0300 Subject: Basic multiple player UI Currently uses meta+(c, x, n, p, up, down) keys as the only interaction method, but that'll change soon! --- backend.js | 11 ++- client.js | 1 - todo.txt | 6 ++ ui.js | 248 ++++++++++++++++++++++++++++++++++++++++++++++++++----------- 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 { -- cgit 1.3.0-6-gf8a5