diff options
Diffstat (limited to 'ui.js')
-rw-r--r-- | ui.js | 1220 |
1 files changed, 1028 insertions, 192 deletions
diff --git a/ui.js b/ui.js index da6cff9..4a92ebe 100644 --- a/ui.js +++ b/ui.js @@ -1,24 +1,45 @@ // The UI in MTUI! Interfaces with the backend to form the complete mtui app. -'use strict' +import {spawn} from 'node:child_process' +import {readFile, writeFile} from 'node:fs/promises' +import path from 'node:path' +import url from 'node:url' -const { getAllCrawlersForArg } = require('./crawlers') -const processSmartPlaylist = require('./smart-playlist') -const UndoManager = require('./undo-manager') +import {orderBy} from 'natural-orderby' +import open from 'open' -const { +import {Button, Form, ListScrollForm, TextInput} from 'tui-lib/ui/controls' +import {Dialog} from 'tui-lib/ui/dialogs' +import {Label, Pane, WrapLabel} from 'tui-lib/ui/presentation' +import {DisplayElement, FocusElement} from 'tui-lib/ui/primitives' + +import * as ansi from 'tui-lib/util/ansi' +import telc from 'tui-lib/util/telchars' +import unic from 'tui-lib/util/unichars' + +import {getAllCrawlersForArg} from './crawlers.js' +import {originalSymbol} from './socket.js' +import processSmartPlaylist from './smart-playlist.js' +import UndoManager from './undo-manager.js' + +import { commandExists, + getSecFromTimestamp, getTimeStringsFromSec, promisifyProcess, - shuffleArray -} = require('./general-util') + shuffleArray, +} from './general-util.js' -const { +import { cloneGrouplike, + collapseGrouplike, countTotalTracks, + findItemObject, flattenGrouplike, getCorrespondingFileForItem, getCorrespondingPlayableForFile, + getFlatTrackList, + getFlatGroupList, getItemPath, getNameWithoutTrackNumber, isGroup, @@ -28,55 +49,19 @@ const { parentSymbol, reverseOrderOfGroups, searchForItem, - shuffleOrderOfGroups -} = require('./playlist-utils') + shuffleOrderOfGroups, +} from './playlist-utils.js' -const { +import { updateRestoredTracksUsingPlaylists, getWaitingTrackData -} = require('./serialized-backend') - -const { - ui: { - Dialog, - DisplayElement, - Label, - Pane, - WrapLabel, - form: { - Button, - FocusElement, - Form, - ListScrollForm, - TextInput, - } - }, - util: { - ansi, - telchars: telc, - unichars: unic, - } -} = require('tui-lib') - -const { - originalSymbol -} = require('./socket') +} from './serialized-backend.js' /* text editor features disabled because theyre very much incomplete and havent * gotten much use from me or anyone afaik! const TuiTextEditor = require('tui-text-editor') */ -const { promisify } = require('util') -const { spawn } = require('child_process') -const { orderBy } = require('natural-orderby') -const fs = require('fs') -const open = require('open') -const path = require('path') -const url = require('url') -const readFile = promisify(fs.readFile) -const writeFile = promisify(fs.writeFile) - const input = {} const keyBindings = [ @@ -116,6 +101,8 @@ const keyBindings = [ // ['isFocusLabels', 'L', {caseless: false}], // todo: better key? to let isToggleLoop be caseless again ['isSelectUp', telc.isShiftUp], ['isSelectDown', telc.isShiftDown], + ['isNextThemeColor', 'c', {caseless: false}], + ['isPreviousThemeColor', 'C', {caseless: false}], ['isPreviousPlayer', telc.isMetaUp], ['isPreviousPlayer', [0x1b, 'p']], @@ -191,7 +178,7 @@ telc.isRight = input.isRight telc.isSelect = input.isSelect telc.isBackspace = input.isBackspace -class AppElement extends FocusElement { +export default class AppElement extends FocusElement { constructor(backend, config = {}) { super() @@ -208,7 +195,8 @@ class AppElement extends FocusElement { canControlQueuePlayers: true, canProcessMetadata: true, canSuspend: true, - menubarColor: 4, // blue + themeColor: 4, // blue + seekToStartThreshold: 3, showTabberPane: true, stopPlayingUponQuit: true, showPartyControls: false @@ -220,6 +208,8 @@ class AppElement extends FocusElement { this.cachedMarkStatuses = new Map() this.editMode = false + this.timestampDictionary = new WeakMap() + // We add this is a child later (so that it's on top of every element). this.menuLayer = new DisplayElement() this.menuLayer.clickThrough = true @@ -228,7 +218,8 @@ class AppElement extends FocusElement { this.menubar = new Menubar(this.showContextMenu) this.addChild(this.menubar) - this.menubar.color = this.config.menubarColor + this.setThemeColor(this.config.themeColor) + this.menubar.on('color', color => this.setThemeColor(color)) this.tabberPane = new Pane() this.addChild(this.tabberPane) @@ -257,7 +248,7 @@ class AppElement extends FocusElement { this.logPane.addChild(this.log) this.logPane.visible = false - this.log.on('log-message', () => { + this.log.on('log message', () => { this.logPane.visible = true this.fixLayout() }) @@ -283,7 +274,7 @@ class AppElement extends FocusElement { this.queueTimeLabel = new Label('') this.queuePane.addChild(this.queueTimeLabel) - this.queueListingElement.on('select', item => this.updateQueueLengthLabel()) + this.queueListingElement.on('select', _item => this.updateQueueLengthLabel()) this.queueListingElement.on('open', item => this.openSpecialOrThroughSystem(item)) this.queueListingElement.on('queue', item => this.play(item)) this.queueListingElement.on('remove', item => this.unqueue(item)) @@ -349,6 +340,7 @@ class AppElement extends FocusElement { {value: 'reverse', label: 'Reverse all'}, {value: 'reverse-groups', label: 'Reverse order of groups'}, {value: 'alphabetic', label: 'Alphabetically'}, + {value: 'alphabetic-groups', label: 'Alphabetize order of groups'}, {value: 'normal', label: 'In order'} ], this.showContextMenu) @@ -369,15 +361,17 @@ class AppElement extends FocusElement { return [ {label: playingTrack ? `("${playingTrack.name}")` : '(No track playing.)'}, {divider: true}, + {element: this.volumeSlider}, + {divider: true}, playingTrack && {element: this.playingControl}, - {element: this.loopingControl}, - {element: this.loopQueueControl}, - {element: this.pauseNextControl}, + playingTrack && {element: this.loopingControl}, + playingTrack && {element: this.pauseNextControl}, {element: this.autoDJControl}, - {element: this.volumeSlider}, {divider: true}, previous && {label: `Previous (${previous.name})`, action: () => this.SQP.playPrevious(playingTrack)}, next && {label: `Next (${next.name})`, action: () => this.SQP.playNext(playingTrack)}, + !next && this.SQP.queueEndMode === 'loop' && + {label: `Next (loop queue)`, action: () => this.SQP.playNext(playingTrack)}, next && {label: '- Play later', action: () => this.playLater(next)} ] }}, @@ -388,6 +382,8 @@ class AppElement extends FocusElement { return [ {label: `(Queue - ${curIndex >= 0 ? `${curIndex + 1}/` : ''}${items.length} items.)`}, {divider: true}, + {element: this.loopModeControl}, + {divider: true}, items.length && {label: 'Shuffle', action: () => this.shuffleQueue()}, items.length && {label: 'Clear', action: () => this.clearQueue()} ] @@ -421,6 +417,20 @@ class AppElement extends FocusElement { getEnabled: () => this.config.canControlPlayback }) + this.loopModeControl = new InlineListPickerElement('Loop queue?', [ + {value: 'end', label: 'Don\'t loop'}, + {value: 'loop', label: 'Loop (same order)'}, + {value: 'shuffle', label: 'Loop (shuffle)'} + ], { + setValue: val => { + if (this.SQP) { + this.SQP.queueEndMode = val + } + }, + getValue: () => this.SQP && this.SQP.queueEndMode, + showContextMenu: this.showContextMenu + }) + this.pauseNextControl = new ToggleControl('Pause when this track ends?', { setValue: val => this.SQP.setPauseNextTrack(val), getValue: () => this.SQP.pauseNextTrack, @@ -441,7 +451,7 @@ class AppElement extends FocusElement { this.autoDJControl = new ToggleControl('Enable Auto-DJ?', { setValue: val => (this.enableAutoDJ = val), - getValue: val => this.enableAutoDJ, + getValue: () => this.enableAutoDJ, getEnabled: () => this.config.canControlPlayback }) @@ -497,10 +507,6 @@ class AppElement extends FocusElement { 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 details', this.handlePlayingDetails) - queuePlayer.on('queue updated', this.handleQueueUpdated) } removeQueuePlayerListenersAndUI(queuePlayer, updateUI = true) { @@ -527,30 +533,39 @@ class AppElement extends FocusElement { this.queuePlayersToActOn.splice(index, 1) } - queuePlayer.removeListener('receivedTimeData', this.handleReceivedTimeData) - queuePlayer.removeListener('playing details', this.handlePlayingDetails) - queuePlayer.removeListener('queue updated', this.handleQueueUpdated) queuePlayer.stopPlaying() } attachBackendListeners() { + // Backend-specialized listeners this.backend.on('processMetadata progress', this.handleProcessMetadataProgress) this.backend.on('added queue player', this.handleAddedQueuePlayer) this.backend.on('removed queue player', this.handleRemovedQueuePlayer) this.backend.on('log message', this.handleLogMessage) this.backend.on('got shared sources', this.handleGotSharedSources) this.backend.on('shared sources updated', this.handleSharedSourcesUpdated) - this.backend.on('set-loop-queue-at-end', this.handleSetLoopQueueAtEnd) + this.backend.on('set loop queue at end', this.handleSetLoopQueueAtEnd) + + // Backend as queue player proxy listeners + this.backend.on('QP: playing details', this.handlePlayingDetails) + this.backend.on('QP: received time data', this.handleReceivedTimeData) + this.backend.on('QP: queue updated', this.handleQueueUpdated) } removeBackendListeners() { + // Backend-specialized listeners this.backend.removeListener('processMetadata progress', this.handleProcessMetadataProgress) this.backend.removeListener('added queue player', this.handleAddedQueuePlayer) this.backend.removeListener('removed queue player', this.handleRemovedQueuePlayer) this.backend.removeListener('log message', this.handleLogMessage) this.backend.removeListener('got shared sources', this.handleGotSharedSources) this.backend.removeListener('shared sources updated', this.handleSharedSourcesUpdated) - this.backend.removeListener('set-loop-queue-at-end', this.handleSetLoopQueueAtEnd) + this.backend.removeListener('set loop queue at end', this.handleSetLoopQueueAtEnd) + + // Backend as queue player proxy listeners + this.backend.removeListener('QP: playing details', this.handlePlayingDetails) + this.backend.removeListener('QP: received time data', this.handleReceivedTimeData) + this.backend.removeListener('QP: queue updated', this.handleQueueUpdated) } handleAddedQueuePlayer(queuePlayer) { @@ -589,7 +604,7 @@ class AppElement extends FocusElement { this.updateQueueLengthLabel() } - async handlePlayingDetails(track, oldTrack, queuePlayer) { + async handlePlayingDetails(queuePlayer, track, oldTrack, startTime) { const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer) if (PIE) { PIE.updateTrack() @@ -597,11 +612,21 @@ class AppElement extends FocusElement { if (queuePlayer === this.SQP) { this.updateQueueLengthLabel() + this.queueListingElement.collapseTimestamps(oldTrack) if (track && this.queueListingElement.currentItem === oldTrack) { this.queueListingElement.selectAndShow(track) } } + // Unfortunately, there isn't really any reliable way to make these work if + // the containing queue isn't of the selected queue player. + const timestampData = track && this.getTimestampData(track) + if (timestampData && queuePlayer === this.SQP) { + if (this.queueListingElement.currentItem === track) { + this.queueListingElement.selectTimestampAtSec(track, startTime) + } + } + if (track && this.enableAutoDJ) { queuePlayer.setVolumeMultiplier(0.5); const message = 'now playing: ' + getNameWithoutTrackNumber(track); @@ -614,7 +639,7 @@ class AppElement extends FocusElement { } } - handleReceivedTimeData(data, queuePlayer) { + handleReceivedTimeData(queuePlayer, timeData, oldTimeData) { const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer) if (PIE) { PIE.updateProgress() @@ -622,6 +647,7 @@ class AppElement extends FocusElement { if (queuePlayer === this.SQP) { this.updateQueueLengthLabel() + this.updateQueueSelection(timeData, oldTimeData) } } @@ -715,7 +741,7 @@ class AppElement extends FocusElement { this.tabber.addTab(grouplikeListing) this.tabber.selectTab(grouplikeListing) - grouplikeListing.on('browse', item => grouplikeListing.loadGrouplike(item)) + grouplikeListing.on('browse', item => this.browse(grouplikeListing, item)) grouplikeListing.on('download', item => this.SQP.download(item)) grouplikeListing.on('open', item => this.openSpecialOrThroughSystem(item)) grouplikeListing.on('queue', (item, opts) => this.handleQueueOptions(item, opts)) @@ -723,7 +749,7 @@ class AppElement extends FocusElement { const updateListingsFor = item => { for (const grouplikeListing of this.tabber.tabberElements) { if (grouplikeListing.grouplike === item) { - grouplikeListing.loadGrouplike(item, false) + this.browse(grouplikeListing, item, false) } } } @@ -811,12 +837,13 @@ class AppElement extends FocusElement { // Sets up event listeners that are common to ordinary grouplike listings // (made by newGrouplikeListing) as well as the queue grouplike listing. - grouplikeListing.pathElement.on('select', (item, child) => this.reveal(item, child)) + grouplikeListing.on('timestamp', (item, time) => this.playOrSeek(item, time)) + grouplikeListing.pathElement.on('select', (item, child) => this.revealInLibrary(item, child)) grouplikeListing.on('menu', (item, el) => this.showMenuForItemElement(el, grouplikeListing)) /* grouplikeListing.on('select', item => this.editNotesFile(item, false)) grouplikeListing.on('edit-notes', item => { - this.reveal(item) + this.revealInLibrary(item) this.editNotesFile(item, true) }) */ @@ -832,7 +859,12 @@ class AppElement extends FocusElement { return menu } - reveal(item, child) { + browse(grouplikeListing, grouplike, ...args) { + this.loadTimestampDataInGrouplike(grouplike) + grouplikeListing.loadGrouplike(grouplike, ...args) + } + + revealInLibrary(item, child) { if (!this.tabberPane.visible) { return } @@ -854,6 +886,13 @@ class AppElement extends FocusElement { } } + revealInQueue(item) { + const queueListing = this.queueListingElement + if (queueListing.selectAndShow(item)) { + this.root.select(queueListing) + } + } + play(item) { if (!this.config.canControlQueue) { return @@ -862,6 +901,14 @@ class AppElement extends FocusElement { this.SQP.play(item) } + playOrSeek(item, time) { + if (!this.config.canControlQueue || !this.config.canControlPlayback) { + return + } + + this.SQP.playOrSeek(item, time) + } + unqueue(item) { if (!this.config.canControlQueue) { return @@ -1094,6 +1141,138 @@ class AppElement extends FocusElement { } */ + expandTimestamps(item, listing) { + listing.expandTimestamps(item) + } + + collapseTimestamps(item, listing) { + listing.collapseTimestamps(item) + } + + toggleTimestamps(item, listing) { + listing.toggleTimestamps(item) + } + + timestampsExpanded(item, listing) { + return listing.timestampsExpanded(item) + } + + hasTimestampsFile(item) { + return !!this.getTimestampsFile(item) + } + + getTimestampsFile(item) { + // Only tracks have timestamp files! + if (!isTrack(item)) { + return false + } + + return getCorrespondingFileForItem(item, '.timestamps.txt') + } + + async loadTimestampDataInGrouplike(grouplike) { + // Only load data for a grouplike once. + if (this.timestampDictionary.has(grouplike)) { + return + } + + this.timestampDictionary.set(grouplike, true) + + // There's no parallelization here, but like, whateeeever. + for (const item of grouplike.items) { + if (!isTrack(item)) { + continue + } + + if (this.timestampDictionary.has(item)) { + continue + } + + if (!this.hasTimestampsFile(item)) { + this.timestampDictionary.set(item, false) + continue + } + + this.timestampDictionary.set(item, null) + const data = await this.readTimestampData(item) + this.timestampDictionary.set(item, data) + } + } + + getTimestampData(item) { + return this.timestampDictionary.get(item) || null + } + + getTimestampAtSec(item, sec) { + const timestampData = this.getTimestampData(item) + if (!timestampData) { + return null + } + + // Just like, start from the end, man. + // Why doesn't JavaScript have a findIndexFromEnd function??? + for (let i = timestampData.length - 1; i >= 0; i--) { + const ts = timestampData[i]; + if ( + ts.timestamp <= sec && + ts.timestampEnd >= sec + ) { + return ts + } + } + + return null + } + + async readTimestampData(item) { + const file = this.getTimestampsFile(item) + + if (!file) { + return null + } + + let filePath + try { + filePath = url.fileURLToPath(new URL(file.url)) + } catch (error) { + return null + } + + let contents + try { + contents = (await readFile(filePath)).toString() + } catch (error) { + return null + } + + if (contents.startsWith('[')) { + try { + return JSON.parse(contents) + } catch (error) { + return null + } + } + + const lines = contents.split('\n') + .filter(line => !line.startsWith('#')) + .filter(line => line) + + const metadata = this.backend.getMetadataFor(item) + const duration = (metadata ? metadata.duration : Infinity) + + const data = lines + .map(line => line.match(/^\s*([0-9:.]+)\s*(\S.*)\s*$/)) + .filter(match => match) + .map(match => ({timestamp: getSecFromTimestamp(match[1]), comment: match[2]})) + .filter(({ timestamp: sec }) => !isNaN(sec)) + .map((cur, i, arr) => + (i + 1 === arr.length + ? {...cur, timestampEnd: duration} + : {...cur, timestampEnd: arr[i + 1].timestamp})) + + return data + } + openSpecialOrThroughSystem(item) { if (item.url.endsWith('.json')) { return this.loadPlaylistOrSource(item.url, true) @@ -1138,21 +1317,119 @@ class AppElement extends FocusElement { } } + skipBackOrSeekToStart() { + // Perform the same action - skipping to the previous track or seeking to + // the start of the current track - for all target queue players. If any is + // past an arbitrary time position (default 3 seconds), seek to start; if + // all are before this position, skip to previous. + + let maxCurSec = 0 + this.forEachQueuePlayerToActOn(qp => { + if (qp.timeData) { + let effectiveCurSec = qp.timeData.curSecTotal + + const ts = (qp.timeData && + this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal)) + + if (ts) { + effectiveCurSec -= ts.timestamp + } + + maxCurSec = Math.max(maxCurSec, effectiveCurSec) + } + }) + + if (Math.floor(maxCurSec) < this.config.seekToStartThreshold) { + this.skipBack() + } else { + this.seekToStart() + } + } + + seekToStart() { + this.actOnQueuePlayers(qp => qp.seekToStart()) + this.actOnQueuePlayers(qp => { + if (!qp.playingTrack) { + return + } + + const ts = (qp.timeData && + this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal)) + + if (ts) { + qp.seekTo(ts.timestamp) + return + } + + qp.seekToStart() + }) + } + + skipBack() { + this.actOnQueuePlayers(qp => { + if (!qp.playingTrack) { + return + } + + const ts = (qp.timeData && + this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal)) + + if (ts) { + const timestampData = this.getTimestampData(qp.playingTrack) + const playingTimestampIndex = timestampData.indexOf(ts) + const previous = timestampData[playingTimestampIndex - 1] + if (previous) { + qp.seekTo(previous.timestamp) + return + } + } + + qp.playPrevious(qp.playingTrack, true) + }) + } + + skipAhead() { + this.actOnQueuePlayers(qp => { + if (!qp.playingTrack) { + return + } + + const ts = (qp.timeData && + this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal)) + + if (ts) { + const timestampData = this.getTimestampData(qp.playingTrack) + const playingTimestampIndex = timestampData.indexOf(ts) + const next = timestampData[playingTimestampIndex + 1] + if (next) { + qp.seekTo(next.timestamp) + return + } + } + + qp.playNext(qp.playingTrack, true) + }) + } + actOnQueuePlayers(fn) { - const actOn = this.queuePlayersToActOn.length ? this.queuePlayersToActOn : [this.SQP] - for (const queuePlayer of actOn) { + this.forEachQueuePlayerToActOn(queuePlayer => { fn(queuePlayer) const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer) if (PIE) { PIE.updateProgress() } - } + }) + } + + forEachQueuePlayerToActOn(fn) { + const actOn = this.queuePlayersToActOn.length ? this.queuePlayersToActOn : [this.SQP] + actOn.forEach(fn) } showMenuForItemElement(el, listing) { - const { editMode } = this + // const { editMode } = this const { canControlQueue, canProcessMetadata } = this.config - const anyMarked = editMode && this.markGrouplike.items.length > 0 + // const anyMarked = editMode && this.markGrouplike.items.length > 0 const generatePageForItem = item => { const emitControls = play => () => { @@ -1163,22 +1440,43 @@ class AppElement extends FocusElement { }) } - const rootGroup = getItemPath(item)[0] + const itemPath = getItemPath(item) + const [rootGroup, _partySources, sharedGroup] = itemPath // This is the hack mentioned in the todo!!!! - if ( - this.config.showPartyControls && - rootGroup.isPartySources && - item[originalSymbol] - ) { - item = item[originalSymbol] + if (this.config.showPartyControls && rootGroup.isPartySources) { + const playlists = this.tabber.tabberElements + .map(grouplikeListing => getItemPath(grouplikeListing.grouplike)[0]) + .filter(root => !root.isPartySources) + + let possibleChoices = [] + if (item.downloaderArg) { + possibleChoices = getFlatTrackList({items: playlists}) + } else if (item.items) { + possibleChoices = getFlatGroupList({items: playlists}) + } + if (possibleChoices) { + item = findItemObject(item, possibleChoices) + } + + if (!item) { + return [ + {label: `(Couldn't find this in your music)`} + ] + } } - const hasNotesFile = !!getCorrespondingFileForItem(item, '.txt') + // const hasNotesFile = !!getCorrespondingFileForItem(item, '.txt') + const timestampsItem = this.hasTimestampsFile(item) && (this.timestampsExpanded(item, listing) + ? {label: 'Collapse saved timestamps', action: () => this.collapseTimestamps(item, listing)} + : {label: 'Expand saved timestamps', action: () => this.expandTimestamps(item, listing)} + ) + const isQueued = this.SQP.queueGrouplike.items.includes(item) if (listing.grouplike.isTheQueue && isTrack(item)) { return [ - item[parentSymbol] && this.tabberPane.visible && {label: 'Reveal', action: () => this.reveal(item)}, + item[parentSymbol] && this.tabberPane.visible && {label: 'Reveal in library', action: () => this.revealInLibrary(item)}, + timestampsItem, {divider: true}, canControlQueue && {label: 'Play later', action: () => this.playLater(item)}, canControlQueue && {label: 'Play sooner', action: () => this.playSooner(item)}, @@ -1212,13 +1510,12 @@ class AppElement extends FocusElement { // to move the "mark"/"paste" (etc) code into separate functions, // instead of just defining their behavior inside the listing event // handlers. - /* - editMode && {label: isMarked ? 'Unmark' : 'Mark', action: () => el.emit('mark')}, - anyMarked && {label: 'Paste (above)', action: () => el.emit('paste', {where: 'above'})}, - anyMarked && {label: 'Paste (below)', action: () => el.emit('paste', {where: 'below'})}, + + // editMode && {label: isMarked ? 'Unmark' : 'Mark', action: () => el.emit('mark')}, + // anyMarked && {label: 'Paste (above)', action: () => el.emit('paste', {where: 'above'})}, + // anyMarked && {label: 'Paste (below)', action: () => el.emit('paste', {where: 'below'})}, // anyMarked && !this.isReal && {label: 'Paste', action: () => this.emit('paste')}, // No "above" or "elow" in the label because the "fake" item/row will be replaced (it'll disappear, since there'll be an item in the group) - {divider: true}, - */ + // {divider: true}, ...((this.config.showPartyControls && !rootGroup.isPartySources) ? [ @@ -1231,25 +1528,24 @@ class AppElement extends FocusElement { canControlQueue && isPlayable(item) && {label: 'Play!', action: emitControls(true)}, canControlQueue && isPlayable(item) && {label: 'Queue!', action: emitControls(false)}, {divider: true}, - canProcessMetadata && isGroup(item) && {label: 'Process metadata (new entries)', action: () => setTimeout(() => this.processMetadata(item, false))}, canProcessMetadata && isGroup(item) && {label: 'Process metadata (reprocess)', action: () => setTimeout(() => this.processMetadata(item, true))}, canProcessMetadata && isTrack(item) && {label: 'Process metadata', action: () => setTimeout(() => this.processMetadata(item, true))}, isOpenable(item) && item.url.endsWith('.json') && {label: 'Open (JSON Playlist)', action: () => this.openSpecialOrThroughSystem(item)}, isOpenable(item) && {label: 'Open (System)', action: () => this.openThroughSystem(item)}, - /* - !hasNotesFile && isPlayable(item) && {label: 'Create notes file', action: () => this.editNotesFile(item, true)}, - hasNotesFile && isPlayable(item) && {label: 'Edit notes file', action: () => this.editNotesFile(item, true)}, - */ + // !hasNotesFile && isPlayable(item) && {label: 'Create notes file', action: () => this.editNotesFile(item, true)}, + // hasNotesFile && isPlayable(item) && {label: 'Edit notes file', action: () => this.editNotesFile(item, true)}, canControlQueue && isPlayable(item) && {label: 'Remove from queue', action: () => this.unqueue(item)}, + isTrack(item) && isQueued && {label: 'Reveal in queue', action: () => this.revealInQueue(item)}, {divider: true}, - ]), - ...(item === this.markGrouplike - ? [{label: 'Deselect all', action: () => this.unmarkAll()}] - : [ - this.getMarkStatus(item) !== 'unmarked' && {label: 'Remove from selection', action: () => this.unmarkItem(item)}, - this.getMarkStatus(item) !== 'marked' && {label: 'Add to selection', action: () => this.markItem(item)} + timestampsItem, + ...(item === this.markGrouplike + ? [{label: 'Deselect all', action: () => this.unmarkAll()}] + : [ + this.getMarkStatus(item) !== 'unmarked' && {label: 'Remove from selection', action: () => this.unmarkItem(item)}, + this.getMarkStatus(item) !== 'marked' && {label: 'Add to selection', action: () => this.markItem(item)} + ]) ]) ] } @@ -1261,7 +1557,7 @@ class AppElement extends FocusElement { ].filter(Boolean) // TODO: Implement this! :P - const isMarked = false + // const isMarked = false this.showContextMenu({ x: el.absLeft, @@ -1538,9 +1834,9 @@ class AppElement extends FocusElement { } else if (input.isStop(keyBuf)) { this.actOnQueuePlayers(qp => qp.stopPlaying()) } else if (input.isSkipBack(keyBuf)) { - this.actOnQueuePlayers(qp => qp.playPrevious(qp.playingTrack, true)) + this.skipBackOrSeekToStart() } else if (input.isSkipAhead(keyBuf)) { - this.actOnQueuePlayers(qp => qp.playNext(qp.playingTrack, true)) + this.skipAhead() } } @@ -1658,30 +1954,45 @@ class AppElement extends FocusElement { const oldName = item.name if (isGroup(item)) { - if (order === 'shuffle') { - item = { - name: `${oldName} (shuffled)`, - items: shuffleArray(flattenGrouplike(item).items) - } - } else if (order === 'shuffle-groups') { - item = shuffleOrderOfGroups(item) - item.name = `${oldName} (group order shuffled)` - } else if (order === 'reverse') { - item = { - name: `${oldName} (reversed)`, - items: flattenGrouplike(item).items.reverse() - } - } else if (order === 'reverse-groups') { - item = reverseOrderOfGroups(item) - item.name = `${oldName} (group order reversed)` - } else if (order === 'alphabetic') { - item = { - name: `${oldName} (alphabetic)`, - items: orderBy( - flattenGrouplike(item).items, - t => getNameWithoutTrackNumber(t).replace(/[^a-zA-Z0-9]/g, '') - ) - } + switch (order) { + case 'shuffle': + item = { + name: `${oldName} (shuffled)`, + items: shuffleArray(flattenGrouplike(item).items) + } + break + case 'shuffle-groups': + item = shuffleOrderOfGroups(item) + item.name = `${oldName} (group order shuffled)` + break + case 'reverse': + item = { + name: `${oldName} (reversed)`, + items: flattenGrouplike(item).items.reverse() + } + break + case 'reverse-groups': + item = reverseOrderOfGroups(item) + item.name = `${oldName} (group order reversed)` + break + case 'alphabetic': + item = { + name: `${oldName} (alphabetic)`, + items: orderBy( + flattenGrouplike(item).items, + t => getNameWithoutTrackNumber(t).replace(/[^a-zA-Z0-9]/g, '') + ) + } + break + case 'alphabetic-groups': + item = { + name: `${oldName} (group order alphabetic)`, + items: orderBy( + collapseGrouplike(item).items, + t => t.name.replace(/[^a-zA-Z0-9]/g, '') + ) + } + break } } else { // Make it into a grouplike that just contains itself. @@ -1759,9 +2070,14 @@ class AppElement extends FocusElement { return } - const { playingTrack, timeData } = this.SQP + const { playingTrack, timeData, queueEndMode } = this.SQP const { items } = this.SQP.queueGrouplike - const { currentItem: selectedTrack } = this.queueListingElement + const { + currentInput: currentInput, + currentItem: selectedTrack + } = this.queueListingElement + + const isTimestamp = (currentInput instanceof TimestampGrouplikeItemElement) let trackRemainSec = 0 let trackPassedSec = 0 @@ -1774,6 +2090,7 @@ class AppElement extends FocusElement { const playingIndex = items.indexOf(playingTrack) const selectedIndex = items.indexOf(selectedTrack) + const timestampData = playingTrack && this.getTimestampData(playingTrack) // This will be set to a list of tracks, which will later be used to // calculate a particular duration (as described below) to be shown in @@ -1796,7 +2113,10 @@ class AppElement extends FocusElement { durationRange = items durationAdd = 0 durationSymbol = '' - } else if (selectedIndex === playingIndex) { + } else if ( + selectedIndex === playingIndex && + (!isTimestamp || currentInput.isCurrentTimestamp) + ) { // Remaining length of the queue. if (timeData) { durationRange = items.slice(playingIndex + 1) @@ -1806,20 +2126,40 @@ class AppElement extends FocusElement { durationAdd = 0 } durationSymbol = '' - } else if (selectedIndex < playingIndex) { + } else if ( + selectedIndex < playingIndex || + (isTimestamp && currentInput.data.timestamp <= trackPassedSec) + ) { // Time since the selected track ended. durationRange = items.slice(selectedIndex + 1, playingIndex) durationAdd = trackPassedSec // defaults to 0: no need to check timeData durationSymbol = '-' - } else if (selectedIndex > playingIndex) { + if (isTimestamp) { + if (selectedIndex < playingIndex) { + durationRange.unshift(items[selectedIndex]) + } + durationAdd -= currentInput.data.timestampEnd + } + } else if ( + selectedIndex > playingIndex || + (isTimestamp && currentInput.data.timestamp > trackPassedSec) + ) { // Time until the selected track begins. if (timeData) { - durationRange = items.slice(playingIndex + 1, selectedIndex) - durationAdd = trackRemainSec + if (selectedIndex === playingIndex) { + durationRange = [] + durationAdd = -trackPassedSec + } else { + durationRange = items.slice(playingIndex + 1, selectedIndex) + durationAdd = trackRemainSec + } } else { durationRange = items.slice(playingIndex, selectedIndex) durationAdd = 0 } + if (isTimestamp) { + durationAdd += currentInput.data.timestamp + } durationSymbol = '+' } @@ -1830,28 +2170,117 @@ class AppElement extends FocusElement { const { duration: durationString } = getTimeStringsFromSec(0, durationTotal) this.queueTimeLabel.text = `(${durationSymbol + durationString + approxSymbol})` - let collapseExtraInfo = false if (playingTrack) { - let insertString - const distance = Math.abs(selectedIndex - playingIndex) - if (selectedIndex < playingIndex) { - insertString = ` (-${distance})` - collapseExtraInfo = true - } else if (selectedIndex > playingIndex) { - insertString = ` (+${distance})` - collapseExtraInfo = true - } else { - insertString = '' + let trackPart + let trackPartShort + let trackPartReallyShort + + { + const distance = Math.abs(selectedIndex - playingIndex) + + let insertString + let insertStringShort + if (selectedIndex < playingIndex) { + insertString = ` (-${distance})` + insertStringShort = `-${distance}` + } else if (selectedIndex > playingIndex) { + insertString = ` (+${distance})` + insertStringShort = `+${distance}` + } else { + insertString = '' + insertStringShort = '' + } + + trackPart = `${playingIndex + 1 + insertString} / ${items.length}` + trackPartShort = (insertString + ? `${playingIndex + 1 + insertStringShort}/${items.length}` + : `${playingIndex + 1}/${items.length}`) + trackPartReallyShort = (insertString + ? insertStringShort + : `#${playingIndex + 1}`) + } + + let timestampPart + + if (isTimestamp && selectedIndex === playingIndex) { + const selectedTimestampIndex = timestampData.indexOf(currentInput.data) + + const found = timestampData.findIndex(ts => ts.timestamp > trackPassedSec) + const playingTimestampIndex = (found >= 0 ? found - 1 : 0) + const distance = Math.abs(selectedTimestampIndex - playingTimestampIndex) + + let insertString + if (selectedTimestampIndex < playingTimestampIndex) { + insertString = ` (-${distance})` + } else if (selectedTimestampIndex > playingTimestampIndex) { + insertString = ` (+${distance})` + } else { + insertString = '' + } + + timestampPart = `${playingTimestampIndex + 1 + insertString} / ${timestampData.length}` + } + + let queueLoopPart + let queueLoopPartShort + + if (selectedIndex === playingIndex) { + switch (queueEndMode) { + case 'loop': + queueLoopPart = 'Repeat' + queueLoopPartShort = 'R' + break + case 'shuffle': + queueLoopPart = 'Shuffle' + queueLoopPartShort = 'S' + break + case 'end': + default: + break + } } - this.queueLengthLabel.text = `(${this.SQP.playSymbol} ${playingIndex + 1 + insertString} / ${items.length})` + + let partsTogether + + const all = () => `(${this.SQP.playSymbol} ${partsTogether})` + const tooWide = () => all().length > this.queuePane.contentW + + // goto irl + determineParts: { + if (timestampPart) { + if (queueLoopPart) { + partsTogether = `${trackPart} : ${timestampPart} »${queueLoopPartShort}` + } else { + partsTogether = `(${this.SQP.playSymbol} ${trackPart} : ${timestampPart})` + } + break determineParts + } + + if (queueLoopPart) includeQueueLoop: { + partsTogether = `${trackPart} » ${queueLoopPart}` + if (tooWide()) { + partsTogether = `${trackPart} »${queueLoopPartShort}` + if (tooWide()) { + break includeQueueLoop + } + } + break determineParts + } + + partsTogether = trackPart + if (tooWide()) { + partsTogether = trackPartShort + if (tooWide()) { + partsTogether = trackPartReallyShort + } + } + } + + this.queueLengthLabel.text = all() } else { this.queueLengthLabel.text = `(${items.length})` } - if (this.SQP.loopQueueAtEnd) { - this.queueLengthLabel.text += (collapseExtraInfo ? ` [L${unic.ELLIPSIS}]` : ` [Looping]`) - } - // Layout stuff to position the length and time labels correctly. this.queueLengthLabel.centerInParent() this.queueTimeLabel.centerInParent() @@ -1859,13 +2288,65 @@ class AppElement extends FocusElement { this.queueTimeLabel.y = this.queuePane.contentH - 1 } + updateQueueSelection(timeData, oldTimeData) { + if (!timeData) { + return + } + + const { playingTrack } = this.SQP + const { form } = this.queueListingElement + const { currentInput } = form + + if (!currentInput || currentInput.item !== playingTrack) { + return + } + + const timestamps = this.getTimestampData(playingTrack) + + if (!timestamps) { + return + } + + const tsOld = oldTimeData && + this.getTimestampAtSec(playingTrack, oldTimeData.curSecTotal) + const tsNew = + this.getTimestampAtSec(playingTrack, timeData.curSecTotal) + + if ( + tsNew !== tsOld && + currentInput instanceof TimestampGrouplikeItemElement && + currentInput.data === tsOld + ) { + const index = form.inputs.findIndex(el => ( + el.item === playingTrack && + el instanceof TimestampGrouplikeItemElement && + el.data === tsNew + )) + + if (index === -1) { + return + } + + form.curIndex = index + if (form.isSelected) { + form.updateSelectedElement() + } + form.scrollSelectedElementIntoView() + } + } + + setThemeColor(color) { + this.themeColor = color + this.menubar.color = color + } + get SQP() { // Just a convenient shorthand. return this.selectedQueuePlayer } get selectedQueuePlayer() { return this.getDep('selectedQueuePlayer') } - set selectedQueuePlayer(v) { return this.setDep('selectedQueuePlayer', v) } + set selectedQueuePlayer(v) { this.setDep('selectedQueuePlayer', v) } } class GrouplikeListingElement extends Form { @@ -1930,6 +2411,7 @@ class GrouplikeListingElement extends Form { this.grouplikeData = new WeakMap() this.autoscrollOffset = null + this.expandedTimestamps = [] } getNewForm() { @@ -2018,7 +2500,8 @@ class GrouplikeListingElement extends Form { if (isGroup(this.grouplike)) { this.grouplikeData.set(this.grouplike, { scrollItems: this.form.scrollItems, - currentItem: this.currentItem + currentItem: this.currentItem, + expandedTimestamps: this.expandedTimestamps }) } } @@ -2029,6 +2512,8 @@ class GrouplikeListingElement extends Form { this.form.scrollItems = data.scrollItems this.form.selectAndShow(data.currentItem) this.form.fixLayout() + this.expandedTimestamps = data.expandedTimestamps + this.buildTimestampItems() } } @@ -2104,6 +2589,186 @@ class GrouplikeListingElement extends Form { } } + expandTimestamps(item) { + if (this.grouplike && this.grouplike.items.includes(item)) { + const ET = this.expandedTimestamps + if (!ET.includes(item)) { + this.expandedTimestamps.push(item) + this.buildTimestampItems() + + if (this.currentItem === item) { + if (this.isSelected) { + this.form.selectInput(this.form.inputs[this.form.curIndex + 1]) + } else { + this.form.curIndex += 1 + } + } + } + } + } + + collapseTimestamps(item) { + const ET = this.expandedTimestamps // :alien: + if (ET.includes(item)) { + const restore = (this.currentItem === item) + + ET.splice(ET.indexOf(item), 1) + this.buildTimestampItems() + + if (restore) { + const { form } = this + const index = form.inputs.findIndex(inp => inp.item === item) + form.curIndex = index + if (form.isSelected) { + form.updateSelectedElement() + } + form.scrollSelectedElementIntoView() + } + } + } + + toggleTimestamps(item) { + if (this.timestampsExpanded(item)) { + this.collapseTimestamps(item) + } else { + this.expandTimestamps(item) + } + } + + timestampsExpanded(item) { + this.updateTimestamps() + return this.expandedTimestamps.includes(item) + } + + selectTimestampAtSec(item, sec) { + this.expandTimestamps(item) + + const { form } = this + let index = form.inputs.findIndex(el => ( + el.item === item && + el instanceof TimestampGrouplikeItemElement && + el.data.timestamp >= sec + )) + + if (index === -1) { + index = form.inputs.findIndex(el => el.item === item) + if (index === -1) { + return + } + } + + form.curIndex = index + if (form.isSelected) { + form.updateSelectedElement() + } + form.scrollSelectedElementIntoView() + } + + updateTimestamps() { + const ET = this.expandedTimestamps + if (ET) { + this.expandedTimestamps = ET.filter(item => this.grouplike.items.includes(item)) + } + } + + restoreSelectedInput(restoreInput) { + const { form } = this + const { inputs, currentInput } = form + + if (currentInput === restoreInput) { + return + } + + let inputToSelect + + if (inputs.includes(restoreInput)) { + inputToSelect = restoreInput + } else if (restoreInput instanceof InteractiveGrouplikeItemElement) { + inputToSelect = inputs.find(input => + input.item === restoreInput.item && + input instanceof InteractiveGrouplikeItemElement + ) + } else if (restoreInput instanceof TimestampGrouplikeItemElement) { + inputToSelect = inputs.find(input => + input.data === restoreInput.data && + input instanceof TimestampGrouplikeItemElement + ) + } + + if (!inputToSelect) { + return + } + + form.curIndex = inputs.indexOf(inputToSelect) + if (form.isSelected) { + form.updateSelectedElement() + } + form.scrollSelectedElementIntoView() + } + + buildTimestampItems(restoreInput = this.currentInput) { + const form = this.form + + // Clear up any existing timestamp items, since we're about to generate new + // ones! + form.children = form.children.filter(child => !(child instanceof TimestampGrouplikeItemElement)) + form.inputs = form.inputs.filter(child => !(child instanceof TimestampGrouplikeItemElement)) + + this.updateTimestamps() + + if (!this.expandedTimestamps) { + // Well that's going to have obvious consequences. + return + } + + for (const item of this.expandedTimestamps) { + // Find the main item element. The items we're about to generate will be + // inserted after it. + const mainElementIndex = form.inputs.findIndex(el => ( + el instanceof InteractiveGrouplikeItemElement && + el.item === item + )) + + const timestampData = this.app.getTimestampData(item) + + // Oh no. + // TODO: This should probably error report lol. + if (!timestampData) { + continue + } + + // Generate some items! Just go over the data list and generate one for + // each timestamp. + const tsElements = timestampData.map(ts => { + const el = new TimestampGrouplikeItemElement(item, ts, timestampData, this.app) + el.on('pressed', () => this.emit('timestamp', item, ts.timestamp)) + if (this.grouplike.isTheQueue) { + el.hideMetadata = true + } + return el + }) + + // Stick 'em in. Form doesn't implement an "insert input" function because + // why would life be easy, so we'll mangle the inputs array ourselves. + + form.inputs.splice(mainElementIndex + 1, 0, ...tsElements) + + let previousIndex = mainElementIndex + for (const el of tsElements) { + // We do addChild rather than a simple splice because addChild does more + // stuff than just sticking it in the array (e.g. setting the child's + // .parent property). What if addInput gets updated to do more stuff in + // a similar fashion? Well, then we're scr*wed! :) + form.addChild(el, previousIndex + 1) + previousIndex++ + } + } + + this.restoreSelectedInput(restoreInput) + this.scheduleDrawWithoutPropertyChange() + this.fixAllLayout() + } + buildItems(resetIndex = false) { if (!this.grouplike) { throw new Error('Attempted to call buildItems before a grouplike was loaded') @@ -2111,6 +2776,7 @@ class GrouplikeListingElement extends Form { this.commentLabel.text = this.grouplike.comment || '' + const restoreInput = this.form.currentInput const wasSelected = this.isSelected const form = this.form @@ -2171,8 +2837,11 @@ class GrouplikeListingElement extends Form { } } + this.buildTimestampItems(restoreInput) + // Just to make the selected-track-info bar fill right away (if it wasn't // already filled by a previous this.curIndex set). + /* eslint-disable-next-line no-self-assign */ form.curIndex = form.curIndex this.fixAllLayout() @@ -2194,6 +2863,8 @@ class GrouplikeListingElement extends Form { itemElement.on(evtName, (...data) => this.emit(evtName, itemElement.item, ...data)) } + itemElement.on('toggle-timestamps', () => this.toggleTimestamps(itemElement.item)) + /* itemElement.on('unselected labels', () => { if (!this.expandLabels) { @@ -2302,9 +2973,13 @@ class GrouplikeListingElement extends Form { } get currentItem() { - const element = this.form.inputs[this.form.curIndex] || null + const element = this.currentInput return element && element.item } + + get currentInput() { + return this.form.currentInput + } } class GrouplikeListingForm extends ListScrollForm { @@ -2319,6 +2994,10 @@ class GrouplikeListingForm extends ListScrollForm { } keyPressed(keyBuf) { + if (this.inputs.length === 0) { + return + } + if (input.isSelectUp(keyBuf)) { this.selectUp() } else if (input.isSelectDown(keyBuf)) { @@ -2334,7 +3013,6 @@ class GrouplikeListingForm extends ListScrollForm { set curIndex(newIndex) { this.setDep('curIndex', newIndex) this.emit('select', this.inputs[this.curIndex]) - return newIndex } get curIndex() { @@ -2345,6 +3023,10 @@ class GrouplikeListingForm extends ListScrollForm { return Math.max(0, this.inputs.findIndex(el => el instanceof InteractiveGrouplikeItemElement)) } + get currentInput() { + return this.inputs[this.curIndex] + } + selectAndShow(item) { const index = this.inputs.findIndex(inp => inp.item === item) if (index >= 0) { @@ -2421,7 +3103,6 @@ class GrouplikeListingForm extends ListScrollForm { } dragLeftRange(item) { - const { items } = this.app.markGrouplike if (this.selectMode === 'select') { if (!this.oldMarkedItems.includes(item)) { this.app.unmarkItem(item) @@ -2643,13 +3324,27 @@ class InlineListPickerElement extends FocusElement { // next or previous. (That's the point, it's inline.) This element is mainly // useful in forms or ContextMenus. - constructor(labelText, options, showContextMenu = null) { + constructor(labelText, options, optsOrShowContextMenu = null) { super() + this.labelText = labelText this.options = options - this.showContextMenu = showContextMenu - this.curIndex = 0 + + if (typeof optsOrShowContextMenu === 'function') { + this.showContextMenu = optsOrShowContextMenu + } + + if (typeof optsOrShowContextMenu === 'object') { + const opts = optsOrShowContextMenu + this.showContextMenu = opts.showContextMenu + this.getValue = opts.getValue + this.setValue = opts.setValue + } + this.keyboardIdentifier = this.labelText + + this.curIndex = 0 + this.refreshValue() } fixLayout() { @@ -2700,17 +3395,7 @@ class InlineListPickerElement extends FocusElement { } else if (telc.isLeft(keyBuf)) { this.previousOption() } else if (input.isMenu(keyBuf) && this.showContextMenu) { - this.showContextMenu({ - x: this.absLeft + ansi.measureColumns(this.labelText) + 1, - y: this.absTop + 1, - items: this.options.map(({ value, label }, index) => ({ - label: label, - action: () => { - this.curIndex = index - }, - isDefault: index === this.curIndex - })) - }) + this.showMenu() } else { return true } @@ -2724,6 +3409,8 @@ class InlineListPickerElement extends FocusElement { } else { this.root.select(this) } + } else if (button === 'right') { + this.showMenu() } else if (button === 'scroll-up') { this.previousOption() } else if (button === 'scroll-down') { @@ -2734,11 +3421,40 @@ class InlineListPickerElement extends FocusElement { return false } + + showMenu() { + this.showContextMenu({ + x: this.absLeft + ansi.measureColumns(this.labelText) + 1, + y: this.absTop + 1, + items: this.options.map(({ label }, index) => ({ + label, + action: () => { + this.curIndex = index + }, + isDefault: index === this.curIndex + })) + }) + } + + refreshValue() { + if (this.getValue) { + const value = this.getValue() + const index = this.options.findIndex(opt => opt.value === value) + if (index >= 0) { + this.curIndex = index + } + } + } + nextOption() { this.curIndex++ if (this.curIndex === this.options.length) { this.curIndex = 0 } + + if (this.setValue) { + this.setValue(this.curValue) + } } previousOption() { @@ -2746,6 +3462,10 @@ class InlineListPickerElement extends FocusElement { if (this.curIndex < 0) { this.curIndex = this.options.length - 1 } + + if (this.setValue) { + this.setValue(this.curValue) + } } get curValue() { @@ -2753,7 +3473,7 @@ class InlineListPickerElement extends FocusElement { } get curIndex() { return this.getDep('curIndex') } - set curIndex(v) { return this.setDep('curIndex', v) } + set curIndex(v) { this.setDep('curIndex', v) } } // Quite hacky, but ATM I can't think of any way to neatly tie getDep/setDep @@ -2943,6 +3663,9 @@ class ToggleControl extends FocusElement { } + // Note: ToggleControl doesn't specify refreshValue because it doesn't have an + // internal state for the current value. It sets and draws based on the value + // getter provided externally. toggle() { this.setValue(!this.getValue()) } @@ -3117,6 +3840,8 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement { } else if (telc.isEnter(keyBuf)) { if (isGroup(this.item)) { this.emit('browse') + } else if (this.app.hasTimestampsFile(this.item)) { + this.emit('toggle-timestamps') } else if (isTrack(this.item)) { this.emit('queue', {where: 'next', play: true}) } else if (!this.isPlayable) { @@ -3187,15 +3912,16 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement { writeStatus(writable) { const markStatus = this.app.getMarkStatus(this.item) + const color = this.app.themeColor + 30 if (this.isGroup) { // The ANSI attributes here will apply to the rest of the line, too. // (We don't reset the active attributes until after drawing the rest of // the line.) if (markStatus === 'marked' || markStatus === 'partial') { - writable.write(ansi.setAttributes([ansi.C_BLUE + 10])) + writable.write(ansi.setAttributes([color + 10])) } else { - writable.write(ansi.setAttributes([ansi.C_BLUE, ansi.A_BRIGHT])) + writable.write(ansi.setAttributes([color, ansi.A_BRIGHT])) } } else if (this.isTrack) { if (markStatus === 'marked') { @@ -3229,9 +3955,11 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement { } else if (!this.isPlayable) { writable.write('F') } else if (record.downloading) { - writable.write(braille[Math.floor(Date.now() / 250) % 6]) + writable.write(brailleChar) } else if (this.app.SQP.playingTrack === this.item) { writable.write('\u25B6') + } else if (this.app.hasTimestampsFile(this.item)) { + writable.write(':') } else { writable.write(' ') } @@ -3252,6 +3980,102 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement { } } +class TimestampGrouplikeItemElement extends BasicGrouplikeItemElement { + constructor(item, timestampData, tsDataArray, app) { + super('') + + this.app = app + this.data = timestampData + this.tsData = tsDataArray + this.item = item + this.hideMetadata = false + } + + drawTo(writable) { + const { data, tsData } = this + + const metadata = this.app.backend.getMetadataFor(this.item) + const last = tsData[tsData.length - 1] + const duration = ((metadata && metadata.duration) + || last.timestampEnd !== Infinity && last.timestampEnd + || last.timestamp) + const strings = getTimeStringsFromSec(data.timestamp, duration) + + this.text = ( + /* + (trackDuration + ? `(${strings.timeDone} - ${strings.percentDone})` + : `(${strings.timeDone})`) + + */ + `(${strings.timeDone})` + + (data.comment + ? ` ${data.comment}` + : '') + ) + + if (!this.hideMetadata) { + const durationString = (data.timestampEnd === Infinity + ? 'to end' + : getTimeStringsFromSec(0, data.timestampEnd - data.timestamp).duration) + + // Try to line up so there's one column of negative padding - the duration + // of the timestamp(s) should start one column before the duration of the + // actual track. This makes for a nice nested look! + const rightPadding = ' '.repeat(duration > 3600 ? 4 : 2) + this.rightText = ` (${durationString})` + rightPadding + } + + super.drawTo(writable) + } + + writeStatus(writable) { + let parts = [] + + const color = ansi.setAttributes([ansi.A_BRIGHT, 30 + this.app.themeColor]) + const reset = ansi.setAttributes([ansi.C_RESET]) + + if (this.isCurrentTimestamp) { + parts = [ + color, + ' ', + // reset, + '\u25B6 ', + // color, + ' ' + ] + } else { + parts = [ + color, + ' ', + reset, + ':', + color, + ' ' + ] + } + + for (const part of parts) { + writable.write(part) + } + + this.drawX += 4 + } + + get isCurrentTimestamp() { + const { SQP } = this.app + return ( + SQP.playingTrack === this.item && + SQP.timeData && + SQP.timeData.curSecTotal >= this.data.timestamp && + SQP.timeData.curSecTotal < this.data.timestampEnd + ) + } + + getLeftPadding() { + return 4 + } +} + class ListingJumpElement extends Form { constructor() { super() @@ -3589,7 +4413,6 @@ class PlaybackInfoElement extends FocusElement { this.app.backend.queuePlayers.length > 1 && { label: 'Delete', action: () => { - const { parent } = this this.app.removeQueuePlayer(this.queuePlayer) } } @@ -3690,17 +4513,17 @@ class PlaybackInfoElement extends FocusElement { } get curSecTotal() { return this.getDep('curSecTotal') } - set curSecTotal(v) { return this.setDep('curSecTotal', v) } + set curSecTotal(v) { this.setDep('curSecTotal', v) } get lenSecTotal() { return this.getDep('lenSecTotal') } - set lenSecTotal(v) { return this.setDep('lenSecTotal', v) } + set lenSecTotal(v) { this.setDep('lenSecTotal', v) } get volume() { return this.getDep('volume') } - set volume(v) { return this.setDep('volume', v) } + set volume(v) { this.setDep('volume', v) } get isLooping() { return this.getDep('isLooping') } - set isLooping(v) { return this.setDep('isLooping', v) } + set isLooping(v) { this.setDep('isLooping', v) } get isPaused() { return this.getDep('isPaused') } - set isPaused(v) { return this.setDep('isPaused', v) } + set isPaused(v) { this.setDep('isPaused', v) } get currentTrack() { return this.getDep('currentTrack') } - set currentTrack(v) { return this.setDep('currentTrack', v) } + set currentTrack(v) { this.setDep('currentTrack', v) } } class OpenPlaylistDialog extends Dialog { @@ -4110,6 +4933,16 @@ class ContextMenu extends FocusElement { return } + // Call refreshValue() on any items before they're shown, for items that + // provide it. (This is handy when reusing the same input across a menu that + // might be shown under different contexts.) + for (const item of items) { + const el = item.element + if (!el) continue + if (!el.refreshValue) continue + el.refreshValue() + } + if (!this.root.selectedElement.directAncestors.includes(this)) { this.selectedBefore = this.root.selectedElement } @@ -4461,9 +5294,14 @@ class Menubar extends ListScrollForm { if (this.keyboardSelector.keyPressed(keyBuf)) { return false - } else if (telc.isCaselessLetter(keyBuf, 'c')) { + } else if (input.isNextThemeColor(keyBuf)) { // For fun :) - this.color = (this.color % 8) + 1 + this.color = (this.color === 8 ? 1 : this.color + 1) + this.emit('color', this.color) + return false + } else if (input.isPreviousThemeColor(keyBuf)) { + this.color = (this.color === 1 ? 8 : this.color - 1) + this.emit('color', this.color) return false } else if (telc.isCaselessLetter(keyBuf, 'a')) { this.attribute = (this.attribute % 3) + 1 @@ -4520,9 +5358,9 @@ class Menubar extends ListScrollForm { } get color() { return this.getDep('color') } - set color(v) { return this.setDep('color', v) } + set color(v) { this.setDep('color', v) } get attribute() { return this.getDep('attribute') } - set attribute(v) { return this.setDep('attribute', v) } + set attribute(v) { this.setDep('attribute', v) } } class PartyBanner extends DisplayElement { @@ -4687,7 +5525,7 @@ class Log extends ListScrollForm { this.addInput(logMessage) this.fixLayout() this.scrollToEnd() - this.emit('log-message', logMessage) + this.emit('log message', logMessage) return logMessage } } @@ -4752,5 +5590,3 @@ class LogMessageLabel extends WrapLabel { ].filter(x => x !== null) } } - -module.exports = AppElement |