diff options
-rw-r--r-- | backend.js | 17 | ||||
-rw-r--r-- | general-util.js | 10 | ||||
-rw-r--r-- | players.js | 26 | ||||
-rw-r--r-- | playlist-utils.js | 2 | ||||
-rw-r--r-- | todo.txt | 16 | ||||
-rw-r--r-- | ui.js | 286 |
6 files changed, 341 insertions, 16 deletions
diff --git a/backend.js b/backend.js index ad13127..048aec5 100644 --- a/backend.js +++ b/backend.js @@ -368,7 +368,7 @@ class QueuePlayer extends EventEmitter { } - async play(item) { + async play(item, startTime) { if (this.player === null) { throw new Error('Attempted to play before a player was loaded') } @@ -425,7 +425,7 @@ class QueuePlayer extends EventEmitter { } else { this.player.setPause(false) } - await this.player.playFile(downloadFile) + await this.player.playFile(downloadFile, startTime) } // playingThisTrack now means whether the track played through to the end @@ -510,6 +510,15 @@ class QueuePlayer extends EventEmitter { return false } + async playOrSeek(item, time) { + if (item === this.playingTrack) { + this.seekTo(time) + } else { + this.queue(item, this.playingTrack) + this.play(item, time) + } + } + clearPlayingTrack() { if (this.playingTrack !== null) { const oldTrack = this.playingTrack @@ -531,6 +540,10 @@ class QueuePlayer extends EventEmitter { this.player.seekBack(seconds) } + seekTo(seconds) { + this.player.seekTo(seconds) + } + togglePause() { this.player.togglePause() } diff --git a/general-util.js b/general-util.js index 0a81cdc..f63ae21 100644 --- a/general-util.js +++ b/general-util.js @@ -139,6 +139,16 @@ module.exports.throttlePromise = function(maximumAtOneTime = 10) { return enqueue } +module.exports.getSecFromTimestamp = function(timestamp) { + const parts = timestamp.split(':').map(n => parseInt(n)) + switch (parts.length) { + case 3: return parts[0] * 3600 + parts[1] * 60 + parts[2] + case 2: return parts[0] * 60 + parts[1] + case 1: return parts[0] + default: return 0 + } +} + module.exports.getTimeStringsFromSec = function(curSecTotal, lenSecTotal) { const percentVal = (100 / lenSecTotal) * curSecTotal const percentDone = ( diff --git a/players.js b/players.js index e22e505..b41ce0c 100644 --- a/players.js +++ b/players.js @@ -37,7 +37,7 @@ class Player extends EventEmitter { return this._process } - playFile(file) {} + playFile(file, startTime) {} seekAhead(secs) {} seekBack(secs) {} seekTo(timeInSecs) {} @@ -87,7 +87,7 @@ class Player extends EventEmitter { } module.exports.MPVPlayer = class extends Player { - getMPVOptions(file) { + getMPVOptions(file, startTime) { const opts = ['--no-video', file] if (this.isLooping) { opts.unshift('--loop') @@ -95,15 +95,18 @@ module.exports.MPVPlayer = class extends Player { if (this.isPaused) { opts.unshift('--pause') } + if (startTime) { + opts.unshift('--start=' + startTime) + } opts.unshift('--volume=' + this.volume * this.volumeMultiplier) return opts } - playFile(file) { + playFile(file, startTime) { // The more powerful MPV player. MPV is virtually impossible for a human // being to install; if you're having trouble with it, try the SoX player. - this.process = spawn('mpv', this.getMPVOptions(file).concat(this.processOptions)) + this.process = spawn('mpv', this.getMPVOptions(file, startTime).concat(this.processOptions)) let lastPercent = 0 @@ -146,11 +149,11 @@ module.exports.MPVPlayer = class extends Player { } module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer { - getMPVOptions(file) { - return ['--input-ipc-server=' + this.socat.path, ...super.getMPVOptions(file)] + getMPVOptions(...args) { + return ['--input-ipc-server=' + this.socat.path, ...super.getMPVOptions(...args)] } - playFile(file) { + playFile(file, startTime) { this.removeSocket(this.socketPath) do { @@ -160,7 +163,7 @@ module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer { this.socat = new Socat(this.socketPath) - const mpv = super.playFile(file) + const mpv = super.playFile(file, startTime) mpv.then(() => this.removeSocket(this.socketPath)) @@ -252,13 +255,16 @@ module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer { } module.exports.SoXPlayer = class extends Player { - playFile(file) { + playFile(file, startTime) { // SoX's play command is useful for systems that don't have MPV. SoX is // much easier to install (and probably more commonly installed, as well). // You don't get keyboard controls such as seeking or volume adjusting // with SoX, though. - this.process = spawn('play', [file].concat(this.processOptions)) + this.process = spawn('play', [file].concat( + this.processOptions, + startTime ? ['trim', startTime] : [] + )) this.process.stdout.on('data', data => { process.stdout.write(data.toString()) diff --git a/playlist-utils.js b/playlist-utils.js index 1015748..227c985 100644 --- a/playlist-utils.js +++ b/playlist-utils.js @@ -653,7 +653,7 @@ function getCorrespondingFileForItem(item, extension) { return null } - const checkExtension = item => item.url && path.extname(item.url) === extension + const checkExtension = item => item.url && item.url.endsWith(extension) if (isPlayable(item)) { const parent = item[parentSymbol] diff --git a/todo.txt b/todo.txt index ed7c830..e7a2e31 100644 --- a/todo.txt +++ b/todo.txt @@ -579,3 +579,19 @@ TODO: "Challenge 1 (Tricks)" etc in FP World 3 are "Challenge (Tricks)"! Bad. TODO: Pressing next track (shift+N) on the last track should start the first track, if the queue is being looped. + +TODO: Timestamp files. Oh heck yes. + (Done!) + +TODO: Show the current chunk of a track you're on according to its timestamps, + in both the queue and the main listing! (Put the playing indicator next + to both the track itself and the timestamp element.) + + Possibly tricky, but try to make this tie in with the "time since/until" + indicator thingies at the bottom of the queue listing element too! + +TODO: Some kind of timestamp indicator in the progress bar area??? E.g, name + of the current timestamp, and MAYBE some kind of visual breakup of the + progress bar itself? + +TODO: Timestamp editing within mtui itself????????? diff --git a/ui.js b/ui.js index a167dab..75dfe41 100644 --- a/ui.js +++ b/ui.js @@ -8,6 +8,7 @@ const UndoManager = require('./undo-manager') const { commandExists, + getSecFromTimestamp, getTimeStringsFromSec, promisifyProcess, shuffleArray @@ -208,6 +209,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 @@ -658,7 +661,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)) @@ -666,7 +669,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) } } } @@ -754,6 +757,7 @@ 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.on('timestamp', (item, time) => this.playOrSeek(item, time)) grouplikeListing.pathElement.on('select', (item, child) => this.reveal(item, child)) grouplikeListing.on('menu', (item, el) => this.showMenuForItemElement(el, grouplikeListing)) /* @@ -775,6 +779,11 @@ class AppElement extends FocusElement { return menu } + browse(grouplikeListing, grouplike, ...args) { + this.loadTimestampDataInGrouplike(grouplike) + grouplikeListing.loadGrouplike(grouplike, ...args) + } + reveal(item, child) { if (!this.tabberPane.visible) { return @@ -805,6 +814,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 @@ -1029,6 +1046,106 @@ 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 (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 + } + + 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 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)) + + return data + } + openSpecialOrThroughSystem(item) { if (item.url.endsWith('.json')) { return this.loadPlaylistOrSource(item.url, true) @@ -1099,9 +1216,15 @@ class AppElement extends FocusElement { } 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)} + ) + if (listing.grouplike.isTheQueue && isTrack(item)) { return [ item[parentSymbol] && this.tabberPane.visible && {label: 'Reveal', action: () => this.reveal(item)}, + timestampsItem, {divider: true}, canControlQueue && {label: 'Play later', action: () => this.playLater(item)}, canControlQueue && {label: 'Play sooner', action: () => this.playSooner(item)}, @@ -1161,6 +1284,7 @@ class AppElement extends FocusElement { canControlQueue && isPlayable(item) && {label: 'Remove from queue', action: () => this.unqueue(item)}, {divider: true}, + timestampsItem, ...(item === this.markGrouplike ? [{label: 'Deselect all', action: () => this.unmarkAll()}] : [ @@ -1832,6 +1956,7 @@ class GrouplikeListingElement extends Form { this.grouplikeData = new WeakMap() this.autoscrollOffset = null + this.expandedTimestamps = [] } getNewForm() { @@ -1920,7 +2045,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 }) } } @@ -1931,6 +2057,8 @@ class GrouplikeListingElement extends Form { this.form.scrollItems = data.scrollItems this.form.selectAndShow(data.currentItem) this.form.fixLayout() + this.expandedTimestamps = data.expandedTimestamps + this.buildTimestampItems() } } @@ -2006,6 +2134,109 @@ class GrouplikeListingElement extends Form { } } + expandTimestamps(item) { + if (this.grouplike && this.grouplike.items.includes(item)) { + this.expandedTimestamps.push(item) + this.buildTimestampItems() + } + } + + collapseTimestamps(item) { + const ET = this.expandedTimestamps // :alien: + if (ET.includes(item)) { + ET.splice(ET.indexOf(item), 1) + this.buildTimestampItems() + } + } + + toggleTimestamps(item) { + if (this.timestampsExpanded(item)) { + this.collapseTimestamps(item) + } else { + this.expandTimestamps(item) + } + } + + timestampsExpanded(item) { + this.updateTimestamps() + return this.expandedTimestamps.includes(item) + } + + updateTimestamps() { + const ET = this.expandedTimestamps + if (ET) { + this.expandedTimestamps = ET.filter(item => this.grouplike.items.includes(item)) + } + } + + buildTimestampItems(item) { + const form = this.form + + // We're going to restore this selection later. It's kinda hacky and won't + // work if the selected input was itself a timestamp item, but that + // [extremely RFC voice] hopefully won't happen! + const selectedInput = this.form.inputs[this.form.curIndex] + + // 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.timestamp, ts.comment, this.app) + el.on('pressed', () => this.emit('timestamp', item, ts.timestamp)) + 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++ + } + } + + const index = form.inputs.indexOf(selectedInput) + if (index >= 0) { + form.selectInput(form.inputs.indexOf(selectedInput)) + } + + this.fixAllLayout() + } + buildItems(resetIndex = false) { if (!this.grouplike) { throw new Error('Attempted to call buildItems before a grouplike was loaded') @@ -2077,6 +2308,7 @@ class GrouplikeListingElement extends Form { // already filled by a previous this.curIndex set). form.curIndex = form.curIndex + this.buildTimestampItems() this.fixAllLayout() } @@ -2096,6 +2328,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) { @@ -3019,6 +3253,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) { @@ -3130,6 +3366,8 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement { writable.write('G') } else if (!this.isPlayable) { writable.write('F') + } else if (this.app.hasTimestampsFile(this.item)) { + writable.write(':') } else if (record.downloading) { writable.write(braille[Math.floor(Date.now() / 250) % 6]) } else if (this.app.SQP.playingTrack === this.item) { @@ -3154,6 +3392,48 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement { } } +class TimestampGrouplikeItemElement extends BasicGrouplikeItemElement { + constructor(item, timestamp, comment, app) { + super('') + + this.app = app + this.timestamp = timestamp + this.comment = comment + this.item = item + } + + drawTo(writable) { + const metadata = this.app.backend.getMetadataFor(this.item) + const duration = (metadata && metadata.duration) || 0 + const strings = getTimeStringsFromSec(this.timestamp, duration) + + this.text = ( + (duration + ? `(${strings.timeDone} - ${strings.percentDone})` + : `(${strings.timeDone})`) + + (this.comment + ? ` ${this.comment}` + : '') + ) + + super.drawTo(writable) + } + + writeStatus(writable) { + writable.write(ansi.setAttributes([ansi.A_BRIGHT, ansi.C_CYAN])) + writable.write(' ') + writable.write(ansi.setAttributes([ansi.C_RESET])) + writable.write(':') + writable.write(ansi.setAttributes([ansi.A_BRIGHT, ansi.C_CYAN])) + writable.write(' ') + this.drawX += 4 + } + + getLeftPadding() { + return 4 + } +} + class ListingJumpElement extends Form { constructor() { super() |