From 382d5afc7e2ac24f67b7c891328b8b9bb7e91058 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 13 Jul 2021 23:14:20 -0300 Subject: timestamp files!!! --- ui.js | 286 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 283 insertions(+), 3 deletions(-) (limited to 'ui.js') 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() -- cgit 1.3.0-6-gf8a5