From 382d5afc7e2ac24f67b7c891328b8b9bb7e91058 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 13 Jul 2021 23:14:20 -0300 Subject: timestamp files!!! --- backend.js | 17 +++- general-util.js | 10 ++ players.js | 26 +++-- playlist-utils.js | 2 +- todo.txt | 16 +++ 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() -- cgit 1.3.0-6-gf8a5 From 7af31d6ccb2d1b0c47c0bbb60a7e51c64bb01bf1 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 17 Jul 2021 20:09:09 -0300 Subject: past 3 second threshold, (P) to seek to start --- README.md | 2 +- backend.js | 4 ++++ players.js | 38 ++++++++++++++++++++++++++++++++++++++ ui.js | 33 +++++++++++++++++++++++++++++---- 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 40c4d95..3efa0df 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ You're also welcome to share any ideas, suggestions, and questions through there * [: focus the main track/group listing * ]: focus the queue listing * Enter: play the selected track -* Ctrl+Up, p: play previous track +* Ctrl+Up, p: play previous track or seek to start of current track * Ctrl+Down, n: play next track * o: open the selected item through the system * Shift+Up/Down or drag: select multiple items at once diff --git a/backend.js b/backend.js index 048aec5..51419da 100644 --- a/backend.js +++ b/backend.js @@ -544,6 +544,10 @@ class QueuePlayer extends EventEmitter { this.player.seekTo(seconds) } + seekToStart() { + this.player.seekToStart() + } + togglePause() { this.player.togglePause() } diff --git a/players.js b/players.js index b41ce0c..c707494 100644 --- a/players.js +++ b/players.js @@ -41,6 +41,7 @@ class Player extends EventEmitter { seekAhead(secs) {} seekBack(secs) {} seekTo(timeInSecs) {} + seekToStart() {} volUp(amount) {} volDown(amount) {} setVolume(value) {} @@ -197,6 +198,10 @@ module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer { this.sendCommand('seek', timeInSecs, 'absolute') } + seekToStart() { + this.seekTo(0) + } + volUp(amount) { this.setVolume(this.volume + amount) } @@ -261,6 +266,8 @@ module.exports.SoXPlayer = class extends Player { // You don't get keyboard controls such as seeking or volume adjusting // with SoX, though. + this._file = file + this.process = spawn('play', [file].concat( this.processOptions, startTime ? ['trim', startTime] : [] @@ -313,8 +320,39 @@ module.exports.SoXPlayer = class extends Player { return new Promise(resolve => { this.process.on('close', () => resolve()) + }).then(() => { + if (this._restartPromise) { + const p = this._restartPromise + this._restartPromise = null + return p + } }) } + + async seekToStart() { + // SoX doesn't support a command interface to interact while playback is + // ongoing. However, we can simulate seeking to the start by restarting + // playback altogether. We just need to be careful not to resolve the + // original playback promise before the new one is complete! + + if (!this._file) { + return + } + + let resolve = null + let reject = null + + // The original call of playFile() will yield control to this promise, which + // we bind to the resolve/reject of a new call to playFile(). + this._restartPromise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + + await this.kill() + + this.playFile(this._file).then(resolve, reject) + } } module.exports.getPlayer = async function(name = null, options = []) { diff --git a/ui.js b/ui.js index 75dfe41..3601071 100644 --- a/ui.js +++ b/ui.js @@ -199,6 +199,7 @@ class AppElement extends FocusElement { canProcessMetadata: true, canSuspend: true, menubarColor: 4, // blue + seekToStartThreshold: 3, showTabberPane: true, stopPlayingUponQuit: true }, config) @@ -1190,15 +1191,39 @@ 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(({ timeData }) => { + if (timeData) { + maxCurSec = Math.max(maxCurSec, timeData.curSecTotal) + } + }) + + if (Math.floor(maxCurSec) < this.config.seekToStartThreshold) { + this.actOnQueuePlayers(qp => qp.playPrevious(qp.playingTrack, true)) + } else { + this.actOnQueuePlayers(qp => qp.seekToStart()) + } + } + 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) { @@ -1564,7 +1589,7 @@ 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)) } -- cgit 1.3.0-6-gf8a5 From ee9ba81c076b7986e8fe752b3badbc6a8b7aec50 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 17 Jul 2021 20:32:57 -0300 Subject: show playback icon next to current timestamp --- todo.txt | 6 ++++++ ui.js | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/todo.txt b/todo.txt index e7a2e31..f41609b 100644 --- a/todo.txt +++ b/todo.txt @@ -595,3 +595,9 @@ TODO: Some kind of timestamp indicator in the progress bar area??? E.g, name progress bar itself? TODO: Timestamp editing within mtui itself????????? + +TODO: Automatically expand/collapse timestamp lists in the queue sidebar! + +TODO: Apparently, seeking to a timestamp under a previous track in the queue + doesn't respect the current queue order (i.e. it sticks the track after + the current track). Definitely a bug! diff --git a/ui.js b/ui.js index 3601071..0383807 100644 --- a/ui.js +++ b/ui.js @@ -1138,11 +1138,18 @@ class AppElement extends FocusElement { .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 } @@ -2233,7 +2240,7 @@ class GrouplikeListingElement extends Form { // 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) + const el = new TimestampGrouplikeItemElement(item, ts.timestamp, ts.timestampEnd, ts.comment, this.app) el.on('pressed', () => this.emit('timestamp', item, ts.timestamp)) return el }) @@ -2259,6 +2266,7 @@ class GrouplikeListingElement extends Form { form.selectInput(form.inputs.indexOf(selectedInput)) } + this.scheduleDrawWithoutPropertyChange() this.fixAllLayout() } @@ -3391,12 +3399,12 @@ 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) { writable.write('\u25B6') + } else if (this.app.hasTimestampsFile(this.item)) { + writable.write(':') } else { writable.write(' ') } @@ -3418,11 +3426,12 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement { } class TimestampGrouplikeItemElement extends BasicGrouplikeItemElement { - constructor(item, timestamp, comment, app) { + constructor(item, timestamp, timestampEnd, comment, app) { super('') this.app = app this.timestamp = timestamp + this.timestampEnd = timestampEnd this.comment = comment this.item = item } @@ -3431,11 +3440,15 @@ class TimestampGrouplikeItemElement extends BasicGrouplikeItemElement { const metadata = this.app.backend.getMetadataFor(this.item) const duration = (metadata && metadata.duration) || 0 const strings = getTimeStringsFromSec(this.timestamp, duration) + const stringsEnd = getTimeStringsFromSec(this.timestampEnd, duration) this.text = ( - (duration + /* + (trackDuration ? `(${strings.timeDone} - ${strings.percentDone})` : `(${strings.timeDone})`) + + */ + `(${strings.timeDone})` + (this.comment ? ` ${this.comment}` : '') @@ -3445,12 +3458,41 @@ class TimestampGrouplikeItemElement extends BasicGrouplikeItemElement { } 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(' ') + let parts = [] + + const color = ansi.setAttributes([ansi.A_BRIGHT, ansi.C_CYAN]) + const reset = ansi.setAttributes([ansi.C_RESET]) + + const { SQP } = this.app + if ( + SQP.playingTrack === this.item && + SQP.timeData && + SQP.timeData.curSecTotal >= this.timestamp && + SQP.timeData.curSecTotal < this.timestampEnd + ) { + parts = [ + color, + ' ', + // reset, + '\u25B6 ', + // color, + ' ' + ] + } else { + parts = [ + color, + ' ', + reset, + ':', + color, + ' ' + ] + } + + for (const part of parts) { + writable.write(part) + } + this.drawX += 4 } -- cgit 1.3.0-6-gf8a5 From a55e2dda6b7cd2e413445964f44a98a8a07058a7 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 17 Jul 2021 20:37:08 -0300 Subject: fix playOrSeek messing with queue order --- backend.js | 14 +++++++++++++- todo.txt | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/backend.js b/backend.js index 51419da..e2213d5 100644 --- a/backend.js +++ b/backend.js @@ -511,10 +511,22 @@ class QueuePlayer extends EventEmitter { } async playOrSeek(item, time) { + if (!isTrack(item)) { + // This only makes sense to call with individual tracks! + return + } + if (item === this.playingTrack) { this.seekTo(time) } else { - this.queue(item, this.playingTrack) + // Queue the track, but only if it's not already in the queue, so that we + // respect an existing queue order. + const queue = this.queueGrouplike + const queueIndex = queue.items.indexOf(item) + if (queueIndex === -1) { + this.queue(item, this.playingTrack) + } + this.play(item, time) } } diff --git a/todo.txt b/todo.txt index f41609b..ad1ebac 100644 --- a/todo.txt +++ b/todo.txt @@ -601,3 +601,4 @@ TODO: Automatically expand/collapse timestamp lists in the queue sidebar! TODO: Apparently, seeking to a timestamp under a previous track in the queue doesn't respect the current queue order (i.e. it sticks the track after the current track). Definitely a bug! + (Done - fixed!) -- cgit 1.3.0-6-gf8a5 From 8f0ccabc8b12465771f770508fd0786baf49a518 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 17 Jul 2021 21:18:59 -0300 Subject: show relative timestamp info in queue sidebar! --- todo.txt | 1 + ui.js | 138 ++++++++++++++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 106 insertions(+), 33 deletions(-) diff --git a/todo.txt b/todo.txt index ad1ebac..dadbca7 100644 --- a/todo.txt +++ b/todo.txt @@ -589,6 +589,7 @@ TODO: Show the current chunk of a track you're on according to its timestamps, 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! + (Done - both parts!) 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 diff --git a/ui.js b/ui.js index 0383807..c62f93a 100644 --- a/ui.js +++ b/ui.js @@ -1819,7 +1819,12 @@ class AppElement extends FocusElement { const { playingTrack, timeData } = this.SQP const { items } = this.SQP.queueGrouplike - const { currentItem: selectedTrack } = this.queueListingElement + const { + currentInput: selectedInput, + currentItem: selectedTrack + } = this.queueListingElement + + const isTimestamp = (selectedInput instanceof TimestampGrouplikeItemElement) let trackRemainSec = 0 let trackPassedSec = 0 @@ -1832,6 +1837,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 @@ -1854,7 +1860,10 @@ class AppElement extends FocusElement { durationRange = items durationAdd = 0 durationSymbol = '' - } else if (selectedIndex === playingIndex) { + } else if ( + selectedIndex === playingIndex && + (!isTimestamp || selectedInput.isCurrentTimestamp) + ) { // Remaining length of the queue. if (timeData) { durationRange = items.slice(playingIndex + 1) @@ -1864,20 +1873,40 @@ class AppElement extends FocusElement { durationAdd = 0 } durationSymbol = '' - } else if (selectedIndex < playingIndex) { + } else if ( + selectedIndex < playingIndex || + (isTimestamp && selectedInput.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 -= selectedInput.data.timestampEnd + } + } else if ( + selectedIndex > playingIndex || + (isTimestamp && selectedInput.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 += selectedInput.data.timestamp + } durationSymbol = '+' } @@ -1890,18 +1919,53 @@ class AppElement extends FocusElement { 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 + let trackPart + + { + const distance = Math.abs(selectedIndex - playingIndex) + + let insertString + if (selectedIndex < playingIndex) { + insertString = ` (-${distance})` + collapseExtraInfo = true + } else if (selectedIndex > playingIndex) { + insertString = ` (+${distance})` + collapseExtraInfo = true + } else { + insertString = '' + } + + trackPart = `${playingIndex + 1 + insertString} / ${items.length}` + } + + let timestampPart + + if (isTimestamp && selectedIndex === playingIndex) { + const selectedTimestampIndex = timestampData.indexOf(selectedInput.data) + + const found = timestampData.findIndex(ts => ts.timestamp > timeData.curSecTotal) + const playingTimestampIndex = (found >= 0 ? found - 1 : 0) + const distance = Math.abs(selectedTimestampIndex - playingTimestampIndex) + + let insertString + if (selectedTimestampIndex < playingTimestampIndex) { + insertString = ` (-${distance})` + collapseExtraInfo = true + } else if (selectedTimestampIndex > playingTimestampIndex) { + insertString = ` (+${distance})` + collapseExtraInfo = true + } else { + insertString = '' + } + + timestampPart = `${playingTimestampIndex + 1 + insertString} / ${timestampData.length}` + } + + if (timestampPart) { + this.queueLengthLabel.text = `(${this.SQP.playSymbol} ${trackPart} : ${timestampPart})` } else { - insertString = '' + this.queueLengthLabel.text = `(${this.SQP.playSymbol} ${trackPart})` } - this.queueLengthLabel.text = `(${this.SQP.playSymbol} ${playingIndex + 1 + insertString} / ${items.length})` } else { this.queueLengthLabel.text = `(${items.length})` } @@ -2240,7 +2304,7 @@ class GrouplikeListingElement extends Form { // 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.timestampEnd, ts.comment, this.app) + const el = new TimestampGrouplikeItemElement(item, ts, this.app) el.on('pressed', () => this.emit('timestamp', item, ts.timestamp)) return el }) @@ -2471,9 +2535,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.inputs[this.form.curIndex] || null + } } class GrouplikeListingForm extends ListScrollForm { @@ -3426,21 +3494,21 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement { } class TimestampGrouplikeItemElement extends BasicGrouplikeItemElement { - constructor(item, timestamp, timestampEnd, comment, app) { + constructor(item, timestampData, app) { super('') this.app = app - this.timestamp = timestamp - this.timestampEnd = timestampEnd - this.comment = comment + this.data = timestampData this.item = item } drawTo(writable) { + const { data } = this + const metadata = this.app.backend.getMetadataFor(this.item) const duration = (metadata && metadata.duration) || 0 - const strings = getTimeStringsFromSec(this.timestamp, duration) - const stringsEnd = getTimeStringsFromSec(this.timestampEnd, duration) + const strings = getTimeStringsFromSec(data.timestamp, duration) + const stringsEnd = getTimeStringsFromSec(data.timestampEnd, duration) this.text = ( /* @@ -3449,8 +3517,8 @@ class TimestampGrouplikeItemElement extends BasicGrouplikeItemElement { : `(${strings.timeDone})`) + */ `(${strings.timeDone})` + - (this.comment - ? ` ${this.comment}` + (data.comment + ? ` ${data.comment}` : '') ) @@ -3463,13 +3531,7 @@ class TimestampGrouplikeItemElement extends BasicGrouplikeItemElement { const color = ansi.setAttributes([ansi.A_BRIGHT, ansi.C_CYAN]) const reset = ansi.setAttributes([ansi.C_RESET]) - const { SQP } = this.app - if ( - SQP.playingTrack === this.item && - SQP.timeData && - SQP.timeData.curSecTotal >= this.timestamp && - SQP.timeData.curSecTotal < this.timestampEnd - ) { + if (this.isCurrentTimestamp) { parts = [ color, ' ', @@ -3496,6 +3558,16 @@ class TimestampGrouplikeItemElement extends BasicGrouplikeItemElement { 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 } -- cgit 1.3.0-6-gf8a5 From c30367f90211e6cfa61482bb68f829ee210e5cb6 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 17 Jul 2021 21:43:22 -0300 Subject: auto expand/collapse timestamps (for SQP) --- todo.txt | 4 ++++ ui.js | 21 ++++++++++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/todo.txt b/todo.txt index dadbca7..f8202c2 100644 --- a/todo.txt +++ b/todo.txt @@ -598,8 +598,12 @@ TODO: Some kind of timestamp indicator in the progress bar area??? E.g, name TODO: Timestamp editing within mtui itself????????? TODO: Automatically expand/collapse timestamp lists in the queue sidebar! + (Done!) TODO: Apparently, seeking to a timestamp under a previous track in the queue doesn't respect the current queue order (i.e. it sticks the track after the current track). Definitely a bug! (Done - fixed!) + +TODO: Next/previous buttons should seek between timestamps if there are more + within the same track. diff --git a/ui.js b/ui.js index c62f93a..b67fc8d 100644 --- a/ui.js +++ b/ui.js @@ -549,6 +549,18 @@ class AppElement extends FocusElement { } } + // 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) { + this.queueListingElement.expandTimestamps(track) + } + + const oldTimestampData = oldTrack && this.getTimestampData(oldTrack) + if (oldTimestampData && queuePlayer === this.SQP) { + this.queueListingElement.collapseTimestamps(oldTrack) + } + if (track && this.enableAutoDJ) { queuePlayer.setVolumeMultiplier(0.5); const message = 'now playing: ' + getNameWithoutTrackNumber(track); @@ -1943,7 +1955,7 @@ class AppElement extends FocusElement { if (isTimestamp && selectedIndex === playingIndex) { const selectedTimestampIndex = timestampData.indexOf(selectedInput.data) - const found = timestampData.findIndex(ts => ts.timestamp > timeData.curSecTotal) + const found = timestampData.findIndex(ts => ts.timestamp > trackPassedSec) const playingTimestampIndex = (found >= 0 ? found - 1 : 0) const distance = Math.abs(selectedTimestampIndex - playingTimestampIndex) @@ -2232,8 +2244,11 @@ class GrouplikeListingElement extends Form { expandTimestamps(item) { if (this.grouplike && this.grouplike.items.includes(item)) { - this.expandedTimestamps.push(item) - this.buildTimestampItems() + const ET = this.expandedTimestamps + if (!ET.includes(item)) { + this.expandedTimestamps.push(item) + this.buildTimestampItems() + } } } -- cgit 1.3.0-6-gf8a5 From be3c0c9d03c8257237121bfd89acf25dec36ff48 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 17 Jul 2021 22:04:46 -0300 Subject: make next/previous buttons pay heed to timestamps! --- todo.txt | 6 ++++ ui.js | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 100 insertions(+), 6 deletions(-) diff --git a/todo.txt b/todo.txt index f8202c2..fa437df 100644 --- a/todo.txt +++ b/todo.txt @@ -607,3 +607,9 @@ TODO: Apparently, seeking to a timestamp under a previous track in the queue TODO: Next/previous buttons should seek between timestamps if there are more within the same track. + (Done!) + +TODO: Should skipping back to a previous track with timestamps automatically + seek to the final timestamp within that track? I'm undecided, but at the + moment leaning *slightly* towards "no". I may be biased due to it is + harder to code that behavior though! :P diff --git a/ui.js b/ui.js index b67fc8d..bc7f68d 100644 --- a/ui.js +++ b/ui.js @@ -1117,6 +1117,27 @@ class AppElement extends FocusElement { 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) @@ -1217,19 +1238,86 @@ class AppElement extends FocusElement { // all are before this position, skip to previous. let maxCurSec = 0 - this.forEachQueuePlayerToActOn(({ timeData }) => { - if (timeData) { - maxCurSec = Math.max(maxCurSec, timeData.curSecTotal) + this.forEachQueuePlayerToActOn(qp => { + if (qp.timeData) { + let effectiveCurSec = qp.timeData.curSecTotal + + const ts = 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.actOnQueuePlayers(qp => qp.playPrevious(qp.playingTrack, true)) + this.skipBack() } else { - this.actOnQueuePlayers(qp => qp.seekToStart()) + this.seekToStart() } } + seekToStart() { + this.actOnQueuePlayers(qp => qp.seekToStart()) + this.actOnQueuePlayers(qp => { + if (!qp.playingTrack) { + return + } + + const ts = 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 = 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 = 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) { this.forEachQueuePlayerToActOn(queuePlayer => { fn(queuePlayer) @@ -1610,7 +1698,7 @@ class AppElement extends FocusElement { } else if (input.isSkipBack(keyBuf)) { this.skipBackOrSeekToStart() } else if (input.isSkipAhead(keyBuf)) { - this.actOnQueuePlayers(qp => qp.playNext(qp.playingTrack, true)) + this.skipAhead() } } -- cgit 1.3.0-6-gf8a5 From df99d463242a30e863a3c84253549a18e2170d45 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 18 Jul 2021 20:00:24 -0300 Subject: miscellaneous improvements to selection restoring --- backend.js | 9 ++-- ui.js | 180 +++++++++++++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 156 insertions(+), 33 deletions(-) diff --git a/backend.js b/backend.js index e2213d5..e38fe2f 100644 --- a/backend.js +++ b/backend.js @@ -85,8 +85,9 @@ class QueuePlayer extends EventEmitter { this.player.on('printStatusLine', data => { if (this.playingTrack) { + const oldTimeData = this.timeData this.timeData = data - this.emit('received time data', data, this) + this.emit('received time data', data, oldTimeData, this) } }) @@ -368,7 +369,7 @@ class QueuePlayer extends EventEmitter { } - async play(item, startTime) { + async play(item, startTime = 0) { if (this.player === null) { throw new Error('Attempted to play before a player was loaded') } @@ -415,7 +416,7 @@ class QueuePlayer extends EventEmitter { this.timeData = null this.playingTrack = item - this.emit('playing', this.playingTrack, oldTrack, this) + this.emit('playing', this.playingTrack, oldTrack, startTime, this) await this.player.kill() if (this.playedTrackToEnd) { @@ -536,7 +537,7 @@ class QueuePlayer extends EventEmitter { const oldTrack = this.playingTrack this.playingTrack = null this.timeData = null - this.emit('playing', null, oldTrack, this) + this.emit('playing', null, oldTrack, 0, this) } } diff --git a/ui.js b/ui.js index bc7f68d..a5481c8 100644 --- a/ui.js +++ b/ui.js @@ -536,7 +536,7 @@ class AppElement extends FocusElement { this.updateQueueLengthLabel() } - async handlePlaying(track, oldTrack, queuePlayer) { + async handlePlaying(track, oldTrack, startTime, queuePlayer) { const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer) if (PIE) { PIE.updateTrack() @@ -544,6 +544,7 @@ class AppElement extends FocusElement { if (queuePlayer === this.SQP) { this.updateQueueLengthLabel() + this.queueListingElement.collapseTimestamps(oldTrack) if (track && this.queueListingElement.currentItem === oldTrack) { this.queueListingElement.selectAndShow(track) } @@ -553,12 +554,9 @@ class AppElement extends FocusElement { // the containing queue isn't of the selected queue player. const timestampData = track && this.getTimestampData(track) if (timestampData && queuePlayer === this.SQP) { - this.queueListingElement.expandTimestamps(track) - } - - const oldTimestampData = oldTrack && this.getTimestampData(oldTrack) - if (oldTimestampData && queuePlayer === this.SQP) { - this.queueListingElement.collapseTimestamps(oldTrack) + if (this.queueListingElement.currentItem === track) { + this.queueListingElement.selectTimestampAtSec(track, startTime) + } } if (track && this.enableAutoDJ) { @@ -573,7 +571,7 @@ class AppElement extends FocusElement { } } - handleReceivedTimeData(data, queuePlayer) { + handleReceivedTimeData(timeData, oldTimeData, queuePlayer) { const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer) if (PIE) { PIE.updateProgress() @@ -581,6 +579,7 @@ class AppElement extends FocusElement { if (queuePlayer === this.SQP) { this.updateQueueLengthLabel() + this.updateQueueSelection(timeData, oldTimeData) } } @@ -1920,11 +1919,11 @@ class AppElement extends FocusElement { const { playingTrack, timeData } = this.SQP const { items } = this.SQP.queueGrouplike const { - currentInput: selectedInput, + currentInput: currentInput, currentItem: selectedTrack } = this.queueListingElement - const isTimestamp = (selectedInput instanceof TimestampGrouplikeItemElement) + const isTimestamp = (currentInput instanceof TimestampGrouplikeItemElement) let trackRemainSec = 0 let trackPassedSec = 0 @@ -1962,7 +1961,7 @@ class AppElement extends FocusElement { durationSymbol = '' } else if ( selectedIndex === playingIndex && - (!isTimestamp || selectedInput.isCurrentTimestamp) + (!isTimestamp || currentInput.isCurrentTimestamp) ) { // Remaining length of the queue. if (timeData) { @@ -1975,7 +1974,7 @@ class AppElement extends FocusElement { durationSymbol = '' } else if ( selectedIndex < playingIndex || - (isTimestamp && selectedInput.data.timestamp <= trackPassedSec) + (isTimestamp && currentInput.data.timestamp <= trackPassedSec) ) { // Time since the selected track ended. durationRange = items.slice(selectedIndex + 1, playingIndex) @@ -1985,11 +1984,11 @@ class AppElement extends FocusElement { if (selectedIndex < playingIndex) { durationRange.unshift(items[selectedIndex]) } - durationAdd -= selectedInput.data.timestampEnd + durationAdd -= currentInput.data.timestampEnd } } else if ( selectedIndex > playingIndex || - (isTimestamp && selectedInput.data.timestamp > trackPassedSec) + (isTimestamp && currentInput.data.timestamp > trackPassedSec) ) { // Time until the selected track begins. if (timeData) { @@ -2005,7 +2004,7 @@ class AppElement extends FocusElement { durationAdd = 0 } if (isTimestamp) { - durationAdd += selectedInput.data.timestamp + durationAdd += currentInput.data.timestamp } durationSymbol = '+' } @@ -2041,7 +2040,7 @@ class AppElement extends FocusElement { let timestampPart if (isTimestamp && selectedIndex === playingIndex) { - const selectedTimestampIndex = timestampData.indexOf(selectedInput.data) + const selectedTimestampIndex = timestampData.indexOf(currentInput.data) const found = timestampData.findIndex(ts => ts.timestamp > trackPassedSec) const playingTimestampIndex = (found >= 0 ? found - 1 : 0) @@ -2081,6 +2080,53 @@ 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() + } + } + get SQP() { // Just a convenient shorthand. return this.selectedQueuePlayer @@ -2336,6 +2382,14 @@ class GrouplikeListingElement extends Form { 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 + } + } } } } @@ -2343,8 +2397,20 @@ class GrouplikeListingElement extends Form { 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() + } } } @@ -2361,6 +2427,30 @@ class GrouplikeListingElement extends Form { 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) { @@ -2368,13 +2458,43 @@ class GrouplikeListingElement extends Form { } } - buildTimestampItems(item) { - const form = this.form + 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() + } - // 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] + buildTimestampItems(restoreInput = this.currentInput) { + const form = this.form // Clear up any existing timestamp items, since we're about to generate new // ones! @@ -2428,11 +2548,7 @@ class GrouplikeListingElement extends Form { } } - const index = form.inputs.indexOf(selectedInput) - if (index >= 0) { - form.selectInput(form.inputs.indexOf(selectedInput)) - } - + this.restoreSelectedInput(restoreInput) this.scheduleDrawWithoutPropertyChange() this.fixAllLayout() } @@ -2444,6 +2560,7 @@ class GrouplikeListingElement extends Form { this.commentLabel.text = this.grouplike.comment || '' + const restoreInput = this.form.currentInput const wasSelected = this.isSelected const form = this.form @@ -2504,11 +2621,12 @@ 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). form.curIndex = form.curIndex - this.buildTimestampItems() this.fixAllLayout() } @@ -2643,7 +2761,7 @@ class GrouplikeListingElement extends Form { } get currentInput() { - return this.form.inputs[this.form.curIndex] || null + return this.form.currentInput } } @@ -2685,6 +2803,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) { -- cgit 1.3.0-6-gf8a5 From 4e0cb3a38deb8600cbcacb3f87b9e6dba8930aa1 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 6 Aug 2021 15:42:38 -0300 Subject: order: alphabetize order of groups --- todo.txt | 13 +++++++++++++ ui.js | 65 ++++++++++++++++++++++++++++++++++++++++------------------------ 2 files changed, 54 insertions(+), 24 deletions(-) diff --git a/todo.txt b/todo.txt index fa437df..7e5fa1c 100644 --- a/todo.txt +++ b/todo.txt @@ -613,3 +613,16 @@ TODO: Should skipping back to a previous track with timestamps automatically seek to the final timestamp within that track? I'm undecided, but at the moment leaning *slightly* towards "no". I may be biased due to it is harder to code that behavior though! :P + +TODO: The timestamp comment regex should probably skip dashes and other common + punctuation between the timestamp itself and the comment! + +TODO: Pressing ^L to locate the currently playing track (in the queue listing) + should focus the current timestamp, if there is one. + +TODO: I don't think "jump to" (/) works with timestamp items, lol. + +TODO: "Alphabetize order of groups" order option. Listen to the releases of + an artist, or your whole library, alphabetically - or prefix group names + with the date of release and play works chronologically! Or do whatever + other shenanigansy inline metadata you like. diff --git a/ui.js b/ui.js index a5481c8..b586335 100644 --- a/ui.js +++ b/ui.js @@ -16,6 +16,7 @@ const { const { cloneGrouplike, + collapseGrouplike, countTotalTracks, flattenGrouplike, getCorrespondingFileForItem, @@ -326,6 +327,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) @@ -1815,30 +1817,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. -- cgit 1.3.0-6-gf8a5 From 341a34320e89ed1ebc29c5a9c8675d18dd02e185 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 6 Aug 2021 15:57:42 -0300 Subject: reveal in queue --- todo.txt | 5 +++++ ui.js | 17 +++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/todo.txt b/todo.txt index 7e5fa1c..3b29b7e 100644 --- a/todo.txt +++ b/todo.txt @@ -626,3 +626,8 @@ TODO: "Alphabetize order of groups" order option. Listen to the releases of an artist, or your whole library, alphabetically - or prefix group names with the date of release and play works chronologically! Or do whatever other shenanigansy inline metadata you like. + (Done!) + +TODO: "Reveal in queue" option in the context menu for tracks that are part of + the queue! Also, rename existing "Reveal" option to "Reveal in library". + (Done!) diff --git a/ui.js b/ui.js index b586335..49308fe 100644 --- a/ui.js +++ b/ui.js @@ -772,12 +772,12 @@ class AppElement extends FocusElement { // (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.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) }) */ @@ -798,7 +798,7 @@ class AppElement extends FocusElement { grouplikeListing.loadGrouplike(grouplike, ...args) } - reveal(item, child) { + revealInLibrary(item, child) { if (!this.tabberPane.visible) { return } @@ -820,6 +820,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 @@ -1353,10 +1360,11 @@ class AppElement extends FocusElement { ? {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)}, @@ -1415,6 +1423,7 @@ class AppElement extends FocusElement { 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}, timestampsItem, -- cgit 1.3.0-6-gf8a5 From 450d751b76574584b1bce70c2108e7fd86510c8f Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 6 Aug 2021 17:07:18 -0300 Subject: fix sub-groups not loading timestamps --- ui.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui.js b/ui.js index 49308fe..f03c24b 100644 --- a/ui.js +++ b/ui.js @@ -1106,6 +1106,10 @@ class AppElement extends FocusElement { // There's no parallelization here, but like, whateeeever. for (const item of grouplike.items) { + if (!isTrack(item)) { + continue + } + if (this.timestampDictionary.has(item)) { continue } -- cgit 1.3.0-6-gf8a5 From 864b45520acae62962a10f335eab3950ed84c6fc Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 6 Aug 2021 18:27:44 -0300 Subject: get fractional playback pos & duration from mpv This fixes a side-effect of timestamp files with fractional timestamps: mtui always used to assume it was at .000 of the current second, so it would briefly highlight the previous timestamp before completely passing the second the timestamp starts within. --- players.js | 43 +++++++++++++++++++++++++++++++++---------- todo.txt | 3 +++ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/players.js b/players.js index c707494..77f1246 100644 --- a/players.js +++ b/players.js @@ -1,7 +1,13 @@ // stolen from http-music +const { + commandExists, + killProcess, + getTimeStrings, + getTimeStringsFromSec +} = require('./general-util') + const { spawn } = require('child_process') -const { commandExists, killProcess, getTimeStrings } = require('./general-util') const EventEmitter = require('events') const Socat = require('./socat') const fs = require('fs') @@ -88,25 +94,42 @@ class Player extends EventEmitter { } module.exports.MPVPlayer = class extends Player { + // 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. + getMPVOptions(file, startTime) { - const opts = ['--no-video', file] + const opts = [ + `--term-status-msg='${this.getMPVStatusMessage()}'`, + '--no-video', + file + ] + if (this.isLooping) { opts.unshift('--loop') } + if (this.isPaused) { opts.unshift('--pause') } + if (startTime) { opts.unshift('--start=' + startTime) } + opts.unshift('--volume=' + this.volume * this.volumeMultiplier) + return opts } - 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. + getMPVStatusMessage() { + // Note: This function shouldn't include any single-quotes! It probably + // (NOTE: PROBABLY) wouldn't cause any security issues, but it will break + // --term-status-msg parsing and might keep mpv from starting at all. + return '${=time-pos} ${=duration} ${=percent-pos}' + } + + playFile(file, startTime) { this.process = spawn('mpv', this.getMPVOptions(file, startTime).concat(this.processOptions)) let lastPercent = 0 @@ -117,14 +140,14 @@ module.exports.MPVPlayer = class extends Player { } const match = data.toString().match( - /(..):(..):(..) \/ (..):(..):(..) \(([0-9]+)%\)/ + /([0-9.]+) ([0-9.]+) ([0-9.]+)/ ) if (match) { const [ - curHour, curMin, curSec, // ##:##:## - lenHour, lenMin, lenSec, // ##:##:## - percent // ###% + curSecTotal, + lenSecTotal, + percent ] = match.slice(1) if (parseInt(percent) < lastPercent) { @@ -137,7 +160,7 @@ module.exports.MPVPlayer = class extends Player { lastPercent = parseInt(percent) - this.printStatusLine(getTimeStrings({curHour, curMin, curSec, lenHour, lenMin, lenSec})) + this.printStatusLine(getTimeStringsFromSec(curSecTotal, lenSecTotal)) } this.updateVolume(); diff --git a/todo.txt b/todo.txt index 3b29b7e..290b8c0 100644 --- a/todo.txt +++ b/todo.txt @@ -631,3 +631,6 @@ TODO: "Alphabetize order of groups" order option. Listen to the releases of TODO: "Reveal in queue" option in the context menu for tracks that are part of the queue! Also, rename existing "Reveal" option to "Reveal in library". (Done!) + +TODO: Timestamps which have a timestampEnd property (all of them I think?) + should display their duration in the right column. -- cgit 1.3.0-6-gf8a5 From b29f82bc0afca425bb42fecc0583e804701110dd Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 6 Aug 2021 18:30:05 -0300 Subject: read timestamps as JSON when they start with [ ...instead of {. lol --- ui.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui.js b/ui.js index f03c24b..594c6f8 100644 --- a/ui.js +++ b/ui.js @@ -1171,7 +1171,7 @@ class AppElement extends FocusElement { return null } - if (contents.startsWith('{')) { + if (contents.startsWith('[')) { try { return JSON.parse(contents) } catch (error) { -- cgit 1.3.0-6-gf8a5 From 9c7e30f90f0e30535f87fbb28222c9a40940d9ec Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 14 Aug 2021 00:11:15 -0300 Subject: show timestamp durations in main listing --- ui.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/ui.js b/ui.js index 594c6f8..2599427 100644 --- a/ui.js +++ b/ui.js @@ -2559,6 +2559,9 @@ class GrouplikeListingElement extends Form { const tsElements = timestampData.map(ts => { const el = new TimestampGrouplikeItemElement(item, ts, this.app) el.on('pressed', () => this.emit('timestamp', item, ts.timestamp)) + if (this.grouplike.isTheQueue) { + el.hideMetadata = true + } return el }) @@ -3755,6 +3758,7 @@ class TimestampGrouplikeItemElement extends BasicGrouplikeItemElement { this.app = app this.data = timestampData this.item = item + this.hideMetadata = false } drawTo(writable) { @@ -3777,6 +3781,18 @@ class TimestampGrouplikeItemElement extends BasicGrouplikeItemElement { : '') ) + 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) } -- cgit 1.3.0-6-gf8a5 From c047b8c57d4e5012578c072420d2b73dd5b59c4c Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 14 Aug 2021 00:11:39 -0300 Subject: handy combine-album.js utility this isn't exposed via the mtui command so like, just run it directly with node right now lol (this commit also makes "." parse in timestamp positions) --- combine-album.js | 220 ++++++++++++++++++++++++++++++++++++++++++++++++++++ crawlers.js | 16 ++-- package-lock.json | 226 +++++++++++++++++++++++++++++++++++++++++++++++++++++- package.json | 1 + ui.js | 2 +- 5 files changed, 457 insertions(+), 8 deletions(-) create mode 100644 combine-album.js diff --git a/combine-album.js b/combine-album.js new file mode 100644 index 0000000..9fd9cf0 --- /dev/null +++ b/combine-album.js @@ -0,0 +1,220 @@ +'use strict' + +// too lazy to use import syntax :) +const { readdir, readFile, stat, writeFile } = require('fs/promises') +const { spawn } = require('child_process') +const { promisifyProcess, parseOptions } = require('./general-util') +const { musicExtensions } = require('./crawlers') +const path = require('path') +const shellescape = require('shell-escape') + +async function timestamps(files) { + const tsData = [] + + let timestamp = 0 + for (const file of files) { + const args = [ + '-print_format', 'json', + '-show_entries', 'stream=codec_name:format', + '-select_streams', 'a:0', + '-v', 'quiet', + file + ] + + const ffprobe = spawn('ffprobe', args) + + let data = '' + ffprobe.stdout.on('data', chunk => { + data += chunk + }) + + await promisifyProcess(ffprobe, false) + + let result + try { + result = JSON.parse(data) + } catch (error) { + throw new Error(`Failed to parse ffprobe output - cmd: ffprobe ${args.join(' ')}`) + } + + const duration = parseFloat(result.format.duration) + + tsData.push({ + comment: path.basename(file, path.extname(file)), + timestamp, + timestampEnd: (timestamp += duration) + }) + } + + // Serialize to a nicer format. + for (const ts of tsData) { + ts.timestamp = Math.trunc(ts.timestamp * 100) / 100 + ts.timestampEnd = Math.trunc(ts.timestampEnd * 100) / 100 + } + + return tsData +} + +async function main() { + const validFormats = ['txt', 'json'] + + let files = [] + + const opts = await parseOptions(process.argv.slice(2), { + 'format': { + type: 'value', + validate(value) { + if (validFormats.includes(value)) { + return true + } else { + return `a valid output format (${validFormats.join(', ')})` + } + } + }, + + 'no-concat-list': {type: 'flag'}, + 'concat-list': {type: 'value'}, + + 'out': {type: 'value'}, + 'o': {alias: 'out'}, + + [parseOptions.handleDashless]: opt => files.push(opt) + }) + + if (files.length === 0) { + console.error(`Please provide either a directory (album) or a list of tracks to generate timestamps from.`) + return 1 + } + + if (!opts.format) { + opts.format = 'txt' + } + + let defaultOut = false + let outFromDirectory + if (!opts.out) { + opts.out = `timestamps.${opts.format}` + defaultOut = true + } + + const stats = [] + + { + let errored = false + for (const file of files) { + try { + stats.push(await stat(file)) + } catch (error) { + console.error(`Failed to stat ${file}`) + errored = true + } + } + if (errored) { + console.error(`One or more paths provided failed to stat.`) + console.error(`There are probably permission issues preventing access!`) + return 1 + } + } + + if (stats.some(s => !s.isFile() && !s.isDirectory())) { + console.error(`A path was provided which isn't a file or a directory.`); + console.error(`This utility doesn't know what to do with that!`); + return 1 + } + + if (stats.length > 1 && !stats.every(s => s.isFile())) { + if (stats.some(s => s.isFile())) { + console.error(`Please don't provide a mix of files and directories.`) + } else { + console.error(`Please don't provide more than one directory.`) + } + console.error(`This utility is only capable of generating a timestamps file from either one directory (an album) or a list of (audio) files.`) + return 1 + } + + if (files.length === 1 && stats[0].isDirectory()) { + const dir = files[0] + try { + files = await readdir(dir) + files = files.filter(f => musicExtensions.includes(path.extname(f).slice(1))) + } catch (error) { + console.error(`Failed to read ${dir} as directory.`) + console.error(error) + console.error(`Please provide a readable directory or multiple audio files.`) + return 1 + } + files = files.map(file => path.join(dir, file)) + if (defaultOut) { + opts.out = path.join(path.dirname(dir), path.basename(dir) + '.timestamps.' + opts.format) + outFromDirectory = dir.replace(new RegExp(path.sep + '$'), '') + } + } else if (process.argv.length > 3) { + files = process.argv.slice(2) + } else { + console.error(`Please provide an album directory or multiple audio files.`) + return 1 + } + + let tsData + try { + tsData = await timestamps(files) + } catch (error) { + console.error(`Ran into a code error while processing timestamps:`) + console.error(error) + return 1 + } + + let tsText + switch (opts.format) { + case 'json': + tsText = JSON.stringify(tsData) + '\n' + break + case 'txt': + tsText = tsData.map(t => `${t.timestamp} ${t.comment}`).join('\n') + '\n' + break + } + + if (opts.out === '-') { + process.stdout.write(tsText) + } else { + try { + writeFile(opts.out, tsText) + } catch (error) { + console.error(`Failed to write to output file ${opts.out}`) + console.error(`Confirm path is writeable or pass "--out -" to print to stdout`) + return 1 + } + } + + console.log(`Wrote timestamps to ${opts.out}`) + + if (!opts['no-concat-list']) { + const concatOutput = ( + (defaultOut + ? (outFromDirectory || 'album') + : `/path/to/album`) + + path.extname(files[0])) + + const concatListPath = opts['concat-list'] || `/tmp/combine-album-concat.txt` + try { + await writeFile(concatListPath, files.map(file => `file ${path.resolve(shellescape([file]))}`).join('\n') + '\n') + console.log(`Generated ffmpeg concat list at ${concatListPath}`) + console.log(`# To concat:`) + console.log(`ffmpeg -f concat -safe 0 -i ${shellescape([concatListPath])} -c copy ${shellescape([concatOutput])}`) + } catch (error) { + console.warn(`Failed to generate ffmpeg concat list`) + console.warn(error) + } finally { + console.log(`(Pass --no-concat-list to skip this step)`) + } + } + + return 0 +} + +main().then( + code => process.exit(code), + err => { + console.error(err) + process.exit(1) + }) diff --git a/crawlers.js b/crawlers.js index 3f6e391..6af615d 100644 --- a/crawlers.js +++ b/crawlers.js @@ -11,6 +11,15 @@ const { promisify } = require('util') const readDir = promisify(fs.readdir) const stat = promisify(fs.stat) +const musicExtensions = [ + 'ogg', 'oga', + 'wav', 'mp3', 'm4a', 'aac', 'flac', 'opus', + 'mp4', 'mov', 'mkv', + 'mod' +] + +module.exports.musicExtensions = musicExtensions + // Each value is a function with these additional properties: // * crawlerName: The name of the crawler, such as "crawl-http". Used by // getCrawlerByName. @@ -229,12 +238,7 @@ function getHTMLLinks(text) { } */ -function crawlLocal(dirPath, extensions = [ - 'ogg', 'oga', - 'wav', 'mp3', 'm4a', 'aac', 'flac', 'opus', - 'mp4', 'mov', 'mkv', - 'mod' -], isTop = true) { +function crawlLocal(dirPath, extensions = musicExtensions, isTop = true) { // If the passed path is a file:// URL, try to decode it: try { const url = new URL(dirPath) diff --git a/package-lock.json b/package-lock.json index 592e796..f093957 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,8 +1,227 @@ { "name": "mtui", "version": "0.0.1", - "lockfileVersion": 1, + "lockfileVersion": 2, "requires": true, + "packages": { + "": { + "version": "0.0.1", + "license": "GPL-3.0", + "dependencies": { + "command-exists": "^1.2.9", + "expand-home-dir": "0.0.3", + "mkdirp": "^0.5.5", + "natural-orderby": "^2.0.3", + "node-fetch": "^2.6.0", + "open": "^7.0.3", + "sanitize-filename": "^1.6.3", + "shell-escape": "^0.2.0", + "tempy": "^0.2.1", + "tui-lib": "^0.2.1", + "tui-text-editor": "^0.3.1", + "word-wrap": "^1.2.3" + }, + "bin": { + "mtui": "index.js" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/command-exists": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", + "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==" + }, + "node_modules/crypto-random-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz", + "integrity": "sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=", + "engines": { + "node": ">=4" + } + }, + "node_modules/defaults": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", + "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", + "dependencies": { + "clone": "^1.0.2" + } + }, + "node_modules/expand-home-dir": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/expand-home-dir/-/expand-home-dir-0.0.3.tgz", + "integrity": "sha1-ct6KBIbMKKO71wRjU5iCW1tign0=" + }, + "node_modules/is-docker": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.0.0.tgz", + "integrity": "sha512-pJEdRugimx4fBMra5z2/5iRdZ63OhYV0vr0Dwm5+xtW4D1FvRkB8hamMIhnWfyJeDdyr/aa7BDyNbtG38VxgoQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-wsl": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.1.1.tgz", + "integrity": "sha512-umZHcSrwlDHo2TGMXv0DZ8dIUGunZ2Iv68YZnrmCiBPkZ4aaOhtv7pXJKeki9k3qJ3RJr0cDyitcl5wEH3AYog==", + "engines": { + "node": ">=8" + } + }, + "node_modules/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + }, + "node_modules/mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "dependencies": { + "minimist": "^1.2.5" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/natural-orderby": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-2.0.3.tgz", + "integrity": "sha512-p7KTHxU0CUrcOXe62Zfrb5Z13nLvPhSWR/so3kFulUQU0sgUll2Z0LwpsLN351eOOD+hRGu/F1g+6xDfPeD++Q==", + "engines": { + "node": "*" + } + }, + "node_modules/node-fetch": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", + "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==", + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/open": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/open/-/open-7.0.3.tgz", + "integrity": "sha512-sP2ru2v0P290WFfv49Ap8MF6PkzGNnGlAwHweB4WR4mr5d2d0woiCluUeJ218w7/+PmoBy9JmYgD5A4mLcWOFA==", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sanitize-filename": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", + "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/shell-escape": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz", + "integrity": "sha1-aP0CXrBJC09WegJ/C/IkgLX4QTM=" + }, + "node_modules/temp-dir": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", + "integrity": "sha1-CnwOom06Oa+n4OvqnB/AvE2qAR0=", + "engines": { + "node": ">=4" + } + }, + "node_modules/tempy": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.2.1.tgz", + "integrity": "sha512-LB83o9bfZGrntdqPuRdanIVCPReam9SOZKW0fOy5I9X3A854GGWi0tjCqoXEk84XIEYBc/x9Hq3EFop/H5wJaw==", + "dependencies": { + "temp-dir": "^1.0.0", + "unique-string": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha1-QFkjkJWS1W94pYGENLC3hInKXys=", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/tui-lib": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.2.1.tgz", + "integrity": "sha512-AHyhA9neF8tM5dAJnggKIO1W0w5pSVjuuYryp/bMJee6ol2kIzd8p4mbri0Es6/BP9bvPdYFjhSddWwzAE0TpQ==", + "dependencies": { + "wcwidth": "^1.0.1", + "word-wrap": "^1.2.3" + } + }, + "node_modules/tui-text-editor": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/tui-text-editor/-/tui-text-editor-0.3.1.tgz", + "integrity": "sha512-ySLdKfUHwxt6W1hub7Qt7smtuwujRHWxMIwdnO+IOzhd2B9naIg07JDr2LISZ3X+SZg0mvBNcGGeTf+L8bcSpw==", + "dependencies": { + "tui-lib": "^0.1.1" + } + }, + "node_modules/tui-text-editor/node_modules/tui-lib": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.1.1.tgz", + "integrity": "sha512-QAE4axNCJ42IZSNnc2pLOkFtzHqYFgenDyw88JHHRNd8PXTVO8+JIpJArpgAguopd4MmoYaJbreze0BHoWMXfA==", + "dependencies": { + "wcwidth": "^1.0.1", + "word-wrap": "^1.2.3" + } + }, + "node_modules/unique-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz", + "integrity": "sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=", + "dependencies": { + "crypto-random-string": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz", + "integrity": "sha1-9F8VDExm7uloGGUFq5P8u4rWv2E=" + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/word-wrap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "engines": { + "node": ">=0.10.0" + } + } + }, "dependencies": { "clone": { "version": "1.0.4", @@ -82,6 +301,11 @@ "truncate-utf8-bytes": "^1.0.0" } }, + "shell-escape": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz", + "integrity": "sha1-aP0CXrBJC09WegJ/C/IkgLX4QTM=" + }, "temp-dir": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", diff --git a/package.json b/package.json index 421b0a4..e3ea74d 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "node-fetch": "^2.6.0", "open": "^7.0.3", "sanitize-filename": "^1.6.3", + "shell-escape": "^0.2.0", "tempy": "^0.2.1", "tui-lib": "^0.2.1", "tui-text-editor": "^0.3.1", diff --git a/ui.js b/ui.js index 2599427..2da7e02 100644 --- a/ui.js +++ b/ui.js @@ -1187,7 +1187,7 @@ class AppElement extends FocusElement { const duration = (metadata ? metadata.duration : Infinity) const data = lines - .map(line => line.match(/^\s*([0-9:]+)\s*(\S.*)\s*$/)) + .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)) -- cgit 1.3.0-6-gf8a5 From 39732bd31e13c9a785eac1fc724bc9fc768be2ab Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 14 Aug 2021 00:44:47 -0300 Subject: show timestamp hours column whenever appropriate --- combine-album.js | 8 +++++--- general-util.js | 17 +++++++++++++++-- ui.js | 12 ++++++++---- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/combine-album.js b/combine-album.js index 9fd9cf0..946c4c1 100644 --- a/combine-album.js +++ b/combine-album.js @@ -3,7 +3,7 @@ // too lazy to use import syntax :) const { readdir, readFile, stat, writeFile } = require('fs/promises') const { spawn } = require('child_process') -const { promisifyProcess, parseOptions } = require('./general-util') +const { getTimeStringsFromSec, parseOptions, promisifyProcess } = require('./general-util') const { musicExtensions } = require('./crawlers') const path = require('path') const shellescape = require('shell-escape') @@ -164,13 +164,15 @@ async function main() { return 1 } + const duration = tsData[tsData.length - 1].timestampEnd + let tsText switch (opts.format) { case 'json': tsText = JSON.stringify(tsData) + '\n' break case 'txt': - tsText = tsData.map(t => `${t.timestamp} ${t.comment}`).join('\n') + '\n' + tsText = tsData.map(t => `${getTimeStringsFromSec(t.timestamp, duration, true).timeDone} ${t.comment}`).join('\n') + '\n' break } @@ -197,7 +199,7 @@ async function main() { const concatListPath = opts['concat-list'] || `/tmp/combine-album-concat.txt` try { - await writeFile(concatListPath, files.map(file => `file ${path.resolve(shellescape([file]))}`).join('\n') + '\n') + await writeFile(concatListPath, files.map(file => `file ${shellescape([path.resolve(file)])}`).join('\n') + '\n') console.log(`Generated ffmpeg concat list at ${concatListPath}`) console.log(`# To concat:`) console.log(`ffmpeg -f concat -safe 0 -i ${shellescape([concatListPath])} -c copy ${shellescape([concatOutput])}`) diff --git a/general-util.js b/general-util.js index f63ae21..aba1541 100644 --- a/general-util.js +++ b/general-util.js @@ -149,7 +149,7 @@ module.exports.getSecFromTimestamp = function(timestamp) { } } -module.exports.getTimeStringsFromSec = function(curSecTotal, lenSecTotal) { +module.exports.getTimeStringsFromSec = function(curSecTotal, lenSecTotal, fraction = false) { const percentVal = (100 / lenSecTotal) * curSecTotal const percentDone = ( (Math.trunc(percentVal * 100) / 100).toFixed(2) + '%' @@ -159,29 +159,36 @@ module.exports.getTimeStringsFromSec = function(curSecTotal, lenSecTotal) { let leftHour = Math.floor(leftSecTotal / 3600) let leftMin = Math.floor((leftSecTotal - leftHour * 3600) / 60) let leftSec = Math.floor(leftSecTotal - leftHour * 3600 - leftMin * 60) + let leftFrac = lenSecTotal % 1 // Yeah, yeah, duplicate math. let curHour = Math.floor(curSecTotal / 3600) let curMin = Math.floor((curSecTotal - curHour * 3600) / 60) let curSec = Math.floor(curSecTotal - curHour * 3600 - curMin * 60) + let curFrac = curSecTotal % 1 // Wee! let lenHour = Math.floor(lenSecTotal / 3600) let lenMin = Math.floor((lenSecTotal - lenHour * 3600) / 60) let lenSec = Math.floor(lenSecTotal - lenHour * 3600 - lenMin * 60) + let lenFrac = lenSecTotal % 1 const pad = val => val.toString().padStart(2, '0') + const padFrac = val => Math.floor(val * 1000).toString().padEnd(3, '0') curMin = pad(curMin) curSec = pad(curSec) lenMin = pad(lenMin) lenSec = pad(lenSec) leftMin = pad(leftMin) leftSec = pad(leftSec) + curFrac = padFrac(curFrac) + lenFrac = padFrac(lenFrac) + leftFrac = padFrac(leftFrac) // We don't want to display hour counters if the total length is less // than an hour. let timeDone, timeLeft, duration - if (parseInt(lenHour) > 0) { + if (parseInt(lenHour) > 0 || parseInt(curHour) > 0) { timeDone = `${curHour}:${curMin}:${curSec}` timeLeft = `${leftHour}:${leftMin}:${leftSec}` duration = `${lenHour}:${lenMin}:${lenSec}` @@ -191,6 +198,12 @@ module.exports.getTimeStringsFromSec = function(curSecTotal, lenSecTotal) { duration = `${lenMin}:${lenSec}` } + if (fraction) { + timeDone += '.' + curFrac + timeLeft += '.' + leftFrac + duration += '.' + lenFrac + } + return {percentDone, timeDone, timeLeft, duration, curSecTotal, lenSecTotal} } diff --git a/ui.js b/ui.js index 2da7e02..d6452d9 100644 --- a/ui.js +++ b/ui.js @@ -2557,7 +2557,7 @@ class GrouplikeListingElement extends Form { // 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, this.app) + 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 @@ -3752,20 +3752,24 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement { } class TimestampGrouplikeItemElement extends BasicGrouplikeItemElement { - constructor(item, timestampData, app) { + 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 } = this + const { data, tsData } = this const metadata = this.app.backend.getMetadataFor(this.item) - const duration = (metadata && metadata.duration) || 0 + const last = tsData[tsData.length - 1] + const duration = ((metadata && metadata.duration) + || last.timestampEnd !== Infinity && last.timestampEnd + || last.timestamp) const strings = getTimeStringsFromSec(data.timestamp, duration) const stringsEnd = getTimeStringsFromSec(data.timestampEnd, duration) -- cgit 1.3.0-6-gf8a5 From f147a47164ab5fbe37e96d0b27e2e769efa7cdfe Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 14 Aug 2021 00:46:57 -0300 Subject: json todo --- todo.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/todo.txt b/todo.txt index 290b8c0..af31659 100644 --- a/todo.txt +++ b/todo.txt @@ -634,3 +634,6 @@ TODO: "Reveal in queue" option in the context menu for tracks that are part of TODO: Timestamps which have a timestampEnd property (all of them I think?) should display their duration in the right column. + +TODO: Read timestamps as JSON when the file extension is .json. (Right now + any .timestamps.json file is ignored!) -- cgit 1.3.0-6-gf8a5 From b709c931dc01e411e06b7a596b26d36921c13be4 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 14 Aug 2021 19:51:47 -0300 Subject: make secret (c) key change entire ui theme color also add (C) (case-sensitive) to go to previous theme color --- ui.js | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/ui.js b/ui.js index d6452d9..df8d914 100644 --- a/ui.js +++ b/ui.js @@ -109,6 +109,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']], @@ -199,7 +201,7 @@ class AppElement extends FocusElement { canControlQueuePlayers: true, canProcessMetadata: true, canSuspend: true, - menubarColor: 4, // blue + themeColor: 4, // blue seekToStartThreshold: 3, showTabberPane: true, stopPlayingUponQuit: true @@ -221,7 +223,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) @@ -2157,6 +2160,11 @@ class AppElement extends FocusElement { } } + setThemeColor(color) { + this.themeColor = color + this.menubar.color = color + } + get SQP() { // Just a convenient shorthand. return this.selectedQueuePlayer @@ -3684,15 +3692,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') { @@ -3803,7 +3812,7 @@ class TimestampGrouplikeItemElement extends BasicGrouplikeItemElement { writeStatus(writable) { let parts = [] - const color = ansi.setAttributes([ansi.A_BRIGHT, ansi.C_CYAN]) + const color = ansi.setAttributes([ansi.A_BRIGHT, 30 + this.app.themeColor]) const reset = ansi.setAttributes([ansi.C_RESET]) if (this.isCurrentTimestamp) { @@ -5051,9 +5060,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 -- cgit 1.3.0-6-gf8a5 From 24ed3e0bf3542f8cf32ed03399dd455be4d5435f Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 10 Oct 2021 10:34:11 -0300 Subject: fix a bunch of crashes when acting before timeData ...is provided by the player --- ui.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/ui.js b/ui.js index df8d914..1331344 100644 --- a/ui.js +++ b/ui.js @@ -1257,7 +1257,9 @@ class AppElement extends FocusElement { if (qp.timeData) { let effectiveCurSec = qp.timeData.curSecTotal - const ts = this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal) + const ts = (qp.timeData && + this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal)) + if (ts) { effectiveCurSec -= ts.timestamp } @@ -1280,7 +1282,9 @@ class AppElement extends FocusElement { return } - const ts = this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal) + const ts = (qp.timeData && + this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal)) + if (ts) { qp.seekTo(ts.timestamp) return @@ -1296,7 +1300,9 @@ class AppElement extends FocusElement { return } - const ts = this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal) + const ts = (qp.timeData && + this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal)) + if (ts) { const timestampData = this.getTimestampData(qp.playingTrack) const playingTimestampIndex = timestampData.indexOf(ts) @@ -1317,7 +1323,8 @@ class AppElement extends FocusElement { return } - const ts = this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal) + const ts = (qp.timeData && + this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal)) if (ts) { const timestampData = this.getTimestampData(qp.playingTrack) -- cgit 1.3.0-6-gf8a5 From 3fdb4b7961f55a6b0fa24a3f271c3c8090497856 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 10 Oct 2021 10:41:29 -0300 Subject: fix setPause not working for MPV player This fixes the "Paused" option in the menubar! --- players.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/players.js b/players.js index 77f1246..1d64061 100644 --- a/players.js +++ b/players.js @@ -255,8 +255,15 @@ module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer { } setPause(val) { + const wasPaused = this.isPaused this.isPaused = !!val - this.sendCommand('set', 'pause', this.isPaused) + + if (this.isPaused !== wasPaused) { + this.sendCommand('cycle', 'pause') + } + + // For some reason "set pause" doesn't seem to be working anymore: + // this.sendCommand('set', 'pause', this.isPaused) } setLoop(val) { -- cgit 1.3.0-6-gf8a5 From ec0b00d62f5fe02248de71552f5cd3d76589295c Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 10 Oct 2021 10:46:23 -0300 Subject: "Loop mode" option: no loop, loop, shuffle This also reorganizes the menubar options a little. --- backend.js | 18 ++++++++++----- todo.txt | 11 +++++++++ ui.js | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 92 insertions(+), 12 deletions(-) diff --git a/backend.js b/backend.js index e38fe2f..41107d7 100644 --- a/backend.js +++ b/backend.js @@ -66,7 +66,7 @@ class QueuePlayer extends EventEmitter { this.playingTrack = null this.queueGrouplike = {name: 'Queue', isTheQueue: true, items: []} this.pauseNextTrack = false - this.loopQueueAtEnd = false + this.queueEndMode = 'end' // end, loop, shuffle this.playedTrackToEnd = false this.timeData = null @@ -435,10 +435,18 @@ class QueuePlayer extends EventEmitter { if (playingThisTrack) { this.playedTrackToEnd = true if (!this.playNext(item)) { - if (this.loopQueueAtEnd) { - this.playFirst() - } else { - this.clearPlayingTrack() + switch (this.queueEndMode) { + case 'loop': + this.playFirst() + break + case 'shuffle': + this.clearPlayingTrack() + this.shuffleQueue() + this.playFirst() + break + case 'end': + default: + this.clearPlayingTrack() } } } diff --git a/todo.txt b/todo.txt index af31659..90ed41b 100644 --- a/todo.txt +++ b/todo.txt @@ -637,3 +637,14 @@ TODO: Timestamps which have a timestampEnd property (all of them I think?) TODO: Read timestamps as JSON when the file extension is .json. (Right now any .timestamps.json file is ignored!) + +TODO: "Remove from queue" seems to always restore the cursor to a non-timestamp + input. This might be an issue with other queue-modifying actions too! + +TODO: The "From: " text in the playback info element *does* cut + off its text in an attempt to not go outside the screen bounds... but it + goes over the info pane edges anyway, so there's probably a math issue + there. + +TODO: "Play later" has a slight chance of keeping the track in the same place, + which is accentuated when there's only a couple tracks left in the queue. diff --git a/ui.js b/ui.js index 1331344..bf67b8e 100644 --- a/ui.js +++ b/ui.js @@ -351,12 +351,13 @@ class AppElement extends FocusElement { return [ {label: playingTrack ? `("${playingTrack.name}")` : '(No track playing.)'}, {divider: true}, + {element: this.loopModeControl}, + {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)}, @@ -403,6 +404,20 @@ class AppElement extends FocusElement { getEnabled: () => this.config.canControlPlayback }) + this.loopModeControl = new InlineListPickerElement('Loop mode', [ + {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, @@ -3153,13 +3168,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() { @@ -3244,11 +3273,26 @@ class InlineListPickerElement extends FocusElement { return false } + + 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() { @@ -3256,6 +3300,10 @@ class InlineListPickerElement extends FocusElement { if (this.curIndex < 0) { this.curIndex = this.options.length - 1 } + + if (this.setValue) { + this.setValue(this.curValue) + } } get curValue() { @@ -3453,6 +3501,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()) } @@ -4716,6 +4767,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 } -- cgit 1.3.0-6-gf8a5 From fc1da6ee8ea604f1f6fcf2d0c82775fc7eeb8e32 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 10 Oct 2021 10:58:36 -0300 Subject: update controls to loop queue on last track --- backend.js | 41 +++++++++++++++++++++++++---------------- todo.txt | 5 +++-- ui.js | 2 ++ 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/backend.js b/backend.js index 41107d7..36344be 100644 --- a/backend.js +++ b/backend.js @@ -434,27 +434,19 @@ class QueuePlayer extends EventEmitter { if (playingThisTrack) { this.playedTrackToEnd = true - if (!this.playNext(item)) { - switch (this.queueEndMode) { - case 'loop': - this.playFirst() - break - case 'shuffle': - this.clearPlayingTrack() - this.shuffleQueue() - this.playFirst() - break - case 'end': - default: - this.clearPlayingTrack() - } - } + this.playNext(item) } } playNext(track, automaticallyQueueNextTrack = false) { if (!track) return false + // Auto-queue is nice but it should only happen when the queue hasn't been + // explicitly set to loop. + automaticallyQueueNextTrack = ( + automaticallyQueueNextTrack && + this.queueEndMode === 'end') + const queue = this.queueGrouplike let queueIndex = queue.items.indexOf(track) if (queueIndex === -1) return false @@ -473,7 +465,7 @@ class QueuePlayer extends EventEmitter { this.queue(nextItem) queueIndex = queue.items.length - 1 } else { - return false + return this.playNextAtQueueEnd() } } @@ -519,6 +511,23 @@ class QueuePlayer extends EventEmitter { return false } + playNextAtQueueEnd() { + switch (this.queueEndMode) { + case 'loop': + this.playFirst() + return true + case 'shuffle': + this.clearPlayingTrack() + this.shuffleQueue() + this.playFirst() + return true + case 'end': + default: + this.clearPlayingTrack() + return false + } + } + async playOrSeek(item, time) { if (!isTrack(item)) { // This only makes sense to call with individual tracks! diff --git a/todo.txt b/todo.txt index 90ed41b..a6ce5ef 100644 --- a/todo.txt +++ b/todo.txt @@ -577,8 +577,9 @@ TODO: "BAM #45.3 - no" displays as "BAM #45.no" in the queue? Seems wrong! TODO: "Challenge 1 (Tricks)" etc in FP World 3 are "Challenge (Tricks)"! Bad. (Done!) -TODO: Pressing next track (shift+N) on the last track should start the first - track, if the queue is being looped. +TODO: Pressing next track (N) on the last track should start the first track, + if the queue is being looped. + (Done!) TODO: Timestamp files. Oh heck yes. (Done!) diff --git a/ui.js b/ui.js index bf67b8e..b8dd63c 100644 --- a/ui.js +++ b/ui.js @@ -361,6 +361,8 @@ class AppElement extends FocusElement { {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)} ] }}, -- cgit 1.3.0-6-gf8a5 From ea62f8747d835a27b36f5a19de0ebcf6fce908cd Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 10 Oct 2021 13:58:44 -0300 Subject: only load ~/Music if no other sources provided --- client.js | 10 ---------- index.js | 11 +++++++++++ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/client.js b/client.js index aa854ed..ec1ab60 100644 --- a/client.js +++ b/client.js @@ -4,7 +4,6 @@ const AppElement = require('./ui') const processSmartPlaylist = require('./smart-playlist') -const os = require('os') const { ui: { @@ -63,15 +62,6 @@ const setupClient = async ({backend, writable, interfacer, appConfig}) => { cleanTerminal() }) - let grouplike = { - name: 'My ~/Music Library', - comment: ( - '(Add tracks and folders to ~/Music to make them show up here,' + - ' or pass mtui your own playlist.json file!)'), - source: ['crawl-local', os.homedir() + '/Music'] - } - await appElement.loadPlaylistOrSource(grouplike, true) - root.select(appElement) // Load up initial state diff --git a/index.js b/index.js index 444d579..b320812 100755 --- a/index.js +++ b/index.js @@ -29,6 +29,7 @@ const { const { promisify } = require('util') const fs = require('fs') +const os = require('os') const readFile = promisify(fs.readFile) const writeFile = promisify(fs.writeFile) @@ -123,6 +124,16 @@ async function main() { root.renderNow() }) + if (playlistSources.length === 0) { + playlistSources.push({ + name: 'My ~/Music Library', + comment: ( + '(Add tracks and folders to ~/Music to make them show up here,' + + ' or pass mtui your own playlist.json file!)'), + source: ['crawl-local', os.homedir() + '/Music'] + }) + } + const loadPlaylists = async () => { for (const source of playlistSources) { await appElement.loadPlaylistOrSource(source, true) -- cgit 1.3.0-6-gf8a5 From 8fdf8a581d9362f1a09b2f091aa48748875797f3 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 10 Oct 2021 14:02:59 -0300 Subject: don't explode if grouplike form is empty! --- ui.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui.js b/ui.js index b8dd63c..6d07f2b 100644 --- a/ui.js +++ b/ui.js @@ -2842,6 +2842,10 @@ class GrouplikeListingForm extends ListScrollForm { } keyPressed(keyBuf) { + if (this.inputs.length === 0) { + return + } + if (input.isSelectUp(keyBuf)) { this.selectUp() } else if (input.isSelectDown(keyBuf)) { -- cgit 1.3.0-6-gf8a5 From 7828f9cfded16432e48e6c141c824f54c259048b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 14 Dec 2021 08:32:20 -0400 Subject: npm update stuff --- package-lock.json | 124 +++++++++++++++++++++++++++++++++++++++--------------- package.json | 2 +- 2 files changed, 90 insertions(+), 36 deletions(-) diff --git a/package-lock.json b/package-lock.json index f093957..384235f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "sanitize-filename": "^1.6.3", "shell-escape": "^0.2.0", "tempy": "^0.2.1", - "tui-lib": "^0.2.1", + "tui-lib": "^0.3.2", "tui-text-editor": "^0.3.1", "word-wrap": "^1.2.3" }, @@ -60,17 +60,26 @@ "integrity": "sha1-ct6KBIbMKKO71wRjU5iCW1tign0=" }, "node_modules/is-docker": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.0.0.tgz", - "integrity": "sha512-pJEdRugimx4fBMra5z2/5iRdZ63OhYV0vr0Dwm5+xtW4D1FvRkB8hamMIhnWfyJeDdyr/aa7BDyNbtG38VxgoQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "bin": { + "is-docker": "cli.js" + }, "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-wsl": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.1.1.tgz", - "integrity": "sha512-umZHcSrwlDHo2TGMXv0DZ8dIUGunZ2Iv68YZnrmCiBPkZ4aaOhtv7pXJKeki9k3qJ3RJr0cDyitcl5wEH3AYog==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dependencies": { + "is-docker": "^2.0.0" + }, "engines": { "node": ">=8" } @@ -100,17 +109,20 @@ } }, "node_modules/node-fetch": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", - "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==", + "version": "2.6.6", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.6.tgz", + "integrity": "sha512-Z8/6vRlTUChSdIgMa51jxQ4lrw/Jy5SOW10ObaA47/RElsAN2c5Pn8bTgFGWn/ibwzXTE8qwr1Yzx28vsecXEA==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, "engines": { "node": "4.x || >=6.0.0" } }, "node_modules/open": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/open/-/open-7.0.3.tgz", - "integrity": "sha512-sP2ru2v0P290WFfv49Ap8MF6PkzGNnGlAwHweB4WR4mr5d2d0woiCluUeJ218w7/+PmoBy9JmYgD5A4mLcWOFA==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" @@ -155,6 +167,11 @@ "node": ">=4" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" + }, "node_modules/truncate-utf8-bytes": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", @@ -164,12 +181,11 @@ } }, "node_modules/tui-lib": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.2.1.tgz", - "integrity": "sha512-AHyhA9neF8tM5dAJnggKIO1W0w5pSVjuuYryp/bMJee6ol2kIzd8p4mbri0Es6/BP9bvPdYFjhSddWwzAE0TpQ==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.3.2.tgz", + "integrity": "sha512-kW4ABx2Gx7vL+n+fHI7s3UF+0OP8WBqJHuz0P636PlWeKDGti9tLN2fALC/YRWiN6fOVydnFbGcoDjjbF4M+0w==", "dependencies": { - "wcwidth": "^1.0.1", - "word-wrap": "^1.2.3" + "wcwidth": "^1.0.1" } }, "node_modules/tui-text-editor": { @@ -213,6 +229,20 @@ "defaults": "^1.0.3" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", @@ -252,14 +282,17 @@ "integrity": "sha1-ct6KBIbMKKO71wRjU5iCW1tign0=" }, "is-docker": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.0.0.tgz", - "integrity": "sha512-pJEdRugimx4fBMra5z2/5iRdZ63OhYV0vr0Dwm5+xtW4D1FvRkB8hamMIhnWfyJeDdyr/aa7BDyNbtG38VxgoQ==" + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==" }, "is-wsl": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.1.1.tgz", - "integrity": "sha512-umZHcSrwlDHo2TGMXv0DZ8dIUGunZ2Iv68YZnrmCiBPkZ4aaOhtv7pXJKeki9k3qJ3RJr0cDyitcl5wEH3AYog==" + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "requires": { + "is-docker": "^2.0.0" + } }, "minimist": { "version": "1.2.5", @@ -280,14 +313,17 @@ "integrity": "sha512-p7KTHxU0CUrcOXe62Zfrb5Z13nLvPhSWR/so3kFulUQU0sgUll2Z0LwpsLN351eOOD+hRGu/F1g+6xDfPeD++Q==" }, "node-fetch": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", - "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==" + "version": "2.6.6", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.6.tgz", + "integrity": "sha512-Z8/6vRlTUChSdIgMa51jxQ4lrw/Jy5SOW10ObaA47/RElsAN2c5Pn8bTgFGWn/ibwzXTE8qwr1Yzx28vsecXEA==", + "requires": { + "whatwg-url": "^5.0.0" + } }, "open": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/open/-/open-7.0.3.tgz", - "integrity": "sha512-sP2ru2v0P290WFfv49Ap8MF6PkzGNnGlAwHweB4WR4mr5d2d0woiCluUeJ218w7/+PmoBy9JmYgD5A4mLcWOFA==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", "requires": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" @@ -320,6 +356,11 @@ "unique-string": "^1.0.0" } }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" + }, "truncate-utf8-bytes": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", @@ -329,12 +370,11 @@ } }, "tui-lib": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.2.1.tgz", - "integrity": "sha512-AHyhA9neF8tM5dAJnggKIO1W0w5pSVjuuYryp/bMJee6ol2kIzd8p4mbri0Es6/BP9bvPdYFjhSddWwzAE0TpQ==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.3.2.tgz", + "integrity": "sha512-kW4ABx2Gx7vL+n+fHI7s3UF+0OP8WBqJHuz0P636PlWeKDGti9tLN2fALC/YRWiN6fOVydnFbGcoDjjbF4M+0w==", "requires": { - "wcwidth": "^1.0.1", - "word-wrap": "^1.2.3" + "wcwidth": "^1.0.1" } }, "tui-text-editor": { @@ -377,6 +417,20 @@ "defaults": "^1.0.3" } }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", diff --git a/package.json b/package.json index e3ea74d..a5777eb 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "sanitize-filename": "^1.6.3", "shell-escape": "^0.2.0", "tempy": "^0.2.1", - "tui-lib": "^0.2.1", + "tui-lib": "^0.3.2", "tui-text-editor": "^0.3.1", "word-wrap": "^1.2.3" } -- cgit 1.3.0-6-gf8a5 From 39d4ec4d0de41c7f99a3b91a3128b6f3b5e8d3fc Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 14 Dec 2021 08:34:11 -0400 Subject: right click InlineListPickerElement to show menu This menu was already implemented, but previously, it only showed when pressing F (i.e. isMenu). --- ui.js | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/ui.js b/ui.js index 6d07f2b..18652f5 100644 --- a/ui.js +++ b/ui.js @@ -3245,17 +3245,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 } @@ -3269,6 +3259,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') { @@ -3280,6 +3272,20 @@ class InlineListPickerElement extends FocusElement { } + showMenu() { + 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 + })) + }) + } + refreshValue() { if (this.getValue) { const value = this.getValue() -- cgit 1.3.0-6-gf8a5 From 2d9f7ab342403b7f9cf4cf6fbd84bb4c8e9dc731 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 25 Mar 2022 17:42:01 -0300 Subject: quick NPM update Including tui-lib 0.3.3, which fixes a memory leak when resizing the command line screen. --- package-lock.json | 61 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index 384235f..c3f88fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "requires": true, "packages": { "": { + "name": "mtui", "version": "0.0.1", "license": "GPL-3.0", "dependencies": { @@ -85,16 +86,16 @@ } }, "node_modules/minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" }, "node_modules/mkdirp": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", - "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dependencies": { - "minimist": "^1.2.5" + "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" @@ -109,14 +110,22 @@ } }, "node_modules/node-fetch": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.6.tgz", - "integrity": "sha512-Z8/6vRlTUChSdIgMa51jxQ4lrw/Jy5SOW10ObaA47/RElsAN2c5Pn8bTgFGWn/ibwzXTE8qwr1Yzx28vsecXEA==", + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", "dependencies": { "whatwg-url": "^5.0.0" }, "engines": { "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } } }, "node_modules/open": { @@ -181,9 +190,9 @@ } }, "node_modules/tui-lib": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.3.2.tgz", - "integrity": "sha512-kW4ABx2Gx7vL+n+fHI7s3UF+0OP8WBqJHuz0P636PlWeKDGti9tLN2fALC/YRWiN6fOVydnFbGcoDjjbF4M+0w==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.3.3.tgz", + "integrity": "sha512-Cgnzpv3tl4il72spmfDQRCwEjGm2VoS8NgtOEwtFAFVj8k+gfXpIxwok3LW8Ik/vEG9qa0N1tABXf2MEzCTmhQ==", "dependencies": { "wcwidth": "^1.0.1" } @@ -295,16 +304,16 @@ } }, "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" }, "mkdirp": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", - "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "requires": { - "minimist": "^1.2.5" + "minimist": "^1.2.6" } }, "natural-orderby": { @@ -313,9 +322,9 @@ "integrity": "sha512-p7KTHxU0CUrcOXe62Zfrb5Z13nLvPhSWR/so3kFulUQU0sgUll2Z0LwpsLN351eOOD+hRGu/F1g+6xDfPeD++Q==" }, "node-fetch": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.6.tgz", - "integrity": "sha512-Z8/6vRlTUChSdIgMa51jxQ4lrw/Jy5SOW10ObaA47/RElsAN2c5Pn8bTgFGWn/ibwzXTE8qwr1Yzx28vsecXEA==", + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", "requires": { "whatwg-url": "^5.0.0" } @@ -370,9 +379,9 @@ } }, "tui-lib": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.3.2.tgz", - "integrity": "sha512-kW4ABx2Gx7vL+n+fHI7s3UF+0OP8WBqJHuz0P636PlWeKDGti9tLN2fALC/YRWiN6fOVydnFbGcoDjjbF4M+0w==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.3.3.tgz", + "integrity": "sha512-Cgnzpv3tl4il72spmfDQRCwEjGm2VoS8NgtOEwtFAFVj8k+gfXpIxwok3LW8Ik/vEG9qa0N1tABXf2MEzCTmhQ==", "requires": { "wcwidth": "^1.0.1" } -- cgit 1.3.0-6-gf8a5 From d8fbd7f8005cab072d85ff01612e618f986a8ee5 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 13 Apr 2022 15:58:04 -0300 Subject: don't outright crash if metadata fails to read --- metadata-readers.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/metadata-readers.js b/metadata-readers.js index 64f413a..edcac72 100644 --- a/metadata-readers.js +++ b/metadata-readers.js @@ -37,7 +37,11 @@ const metadataReaders = { probeDataString += data }) - await promisifyProcess(ffprobe, false) + try { + await promisifyProcess(ffprobe, false) + } catch (error) { + return null + } let data -- cgit 1.3.0-6-gf8a5 From e8a55f10dd9749ad240b165e318db0a1d2f00a9a Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 1 Jun 2022 23:35:03 -0300 Subject: miscellaneous improvements to queue looping --- backend.js | 9 ++++--- todo.txt | 41 +++++++++++++++++++++++++++++ ui.js | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++------------ 3 files changed, 117 insertions(+), 20 deletions(-) diff --git a/backend.js b/backend.js index 36344be..59c4a48 100644 --- a/backend.js +++ b/backend.js @@ -338,9 +338,11 @@ class QueuePlayer extends EventEmitter { } } - shuffleQueue() { + shuffleQueue(pastPlayingTrackOnly = true) { const queue = this.queueGrouplike - const index = queue.items.indexOf(this.playingTrack) + 1 // This is 0 if no track is playing + const index = (pastPlayingTrackOnly + ? queue.items.indexOf(this.playingTrack) + 1 // This is 0 if no track is playing + : 0) const initialItems = queue.items.slice(0, index) const remainingItems = queue.items.slice(index) const newItems = initialItems.concat(shuffleArray(remainingItems)) @@ -517,8 +519,7 @@ class QueuePlayer extends EventEmitter { this.playFirst() return true case 'shuffle': - this.clearPlayingTrack() - this.shuffleQueue() + this.shuffleQueue(false) this.playFirst() return true case 'end': diff --git a/todo.txt b/todo.txt index a6ce5ef..b10c614 100644 --- a/todo.txt +++ b/todo.txt @@ -649,3 +649,44 @@ TODO: The "From: " text in the playback info element *does* cut TODO: "Play later" has a slight chance of keeping the track in the same place, which is accentuated when there's only a couple tracks left in the queue. + +TODO: "Loop mode" should be an option under the Queue menu, not Playback. + (Done!) + +TODO: "Loop mode" setting should be displayed in the queue's length label! + Probably on the same line as ex. "2 / 3", and only when the currently + playing track is selected. + (Done!) + +TODO: "Clear past current" and "clear up to current" should probably be visible + from the Queue menu! + +TODO: The queue length lebel is kinda busy, and doesn't fit everything so well + on thinner screens. That should get checked out! + (Done!) + +TODO: When the last track in the queue finishes playing and the queue is set to + shuffle, the currently selected index in the queue listing won't be moved + to the new first track (so, reset to zero). The cursor just ends up on + whatever track had been the last in the queue (which is obviously now in + some random location - even possibly the first track, but usually not). + I have a feeling this is the result of shuffling first - which updates + the selected index to go to wherever the last track ended up - and then + playing the first track, but not moving the cursor back to the start + because it's apparently not at the end anymore. But I could be totally + misremembering how this code works. :P --- Nope not even related LOL. + Good guess though! We don't even have to worry about that situation, with + the way selecting the new playing track works. It checks against the + track which *was* playing... but that was getting cleared to make the + shuffle work properly (applying to the whole queue instead of just the + stuff past the current track, which is nothing when you're at its end). + Now we just use a flag to ignore the current playback position. Since the + currently playing track is retained for the 'playing track' event, the + existing code does the rest of the work and selects the newly playing + track (whatever's been shuffled to the start) all on its own! + (Done!) + +TODO: Apparently pressing any key while the UI is booting up will make the + screen totally black and unresponsive (and apparently inactive) until the + screen is resized. I think we're interrupting a control sequence somehow, + and that isn't being handled very well? diff --git a/ui.js b/ui.js index 18652f5..dc9dab9 100644 --- a/ui.js +++ b/ui.js @@ -351,7 +351,6 @@ class AppElement extends FocusElement { return [ {label: playingTrack ? `("${playingTrack.name}")` : '(No track playing.)'}, {divider: true}, - {element: this.loopModeControl}, {element: this.volumeSlider}, {divider: true}, playingTrack && {element: this.playingControl}, @@ -373,6 +372,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()} ] @@ -406,7 +407,7 @@ class AppElement extends FocusElement { getEnabled: () => this.config.canControlPlayback }) - this.loopModeControl = new InlineListPickerElement('Loop mode', [ + this.loopModeControl = new InlineListPickerElement('Loop queue?', [ {value: 'end', label: 'Don\'t loop'}, {value: 'loop', label: 'Loop (same order)'}, {value: 'shuffle', label: 'Loop (shuffle)'} @@ -1973,7 +1974,7 @@ class AppElement extends FocusElement { return } - const { playingTrack, timeData } = this.SQP + const { playingTrack, timeData, queueEndMode } = this.SQP const { items } = this.SQP.queueGrouplike const { currentInput: currentInput, @@ -2073,25 +2074,34 @@ class AppElement extends FocusElement { const { duration: durationString } = getTimeStringsFromSec(0, durationTotal) this.queueTimeLabel.text = `(${durationSymbol + durationString + approxSymbol})` - let collapseExtraInfo = false if (playingTrack) { let trackPart + let trackPartShort + let trackPartReallyShort { const distance = Math.abs(selectedIndex - playingIndex) let insertString + let insertStringShort if (selectedIndex < playingIndex) { insertString = ` (-${distance})` - collapseExtraInfo = true + insertStringShort = `-${distance}` } else if (selectedIndex > playingIndex) { insertString = ` (+${distance})` - collapseExtraInfo = true + 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 @@ -2106,10 +2116,8 @@ class AppElement extends FocusElement { let insertString if (selectedTimestampIndex < playingTimestampIndex) { insertString = ` (-${distance})` - collapseExtraInfo = true } else if (selectedTimestampIndex > playingTimestampIndex) { insertString = ` (+${distance})` - collapseExtraInfo = true } else { insertString = '' } @@ -2117,19 +2125,66 @@ class AppElement extends FocusElement { timestampPart = `${playingTimestampIndex + 1 + insertString} / ${timestampData.length}` } - if (timestampPart) { - this.queueLengthLabel.text = `(${this.SQP.playSymbol} ${trackPart} : ${timestampPart})` - } else { - this.queueLengthLabel.text = `(${this.SQP.playSymbol} ${trackPart})` + 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 + } + } + + 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() -- cgit 1.3.0-6-gf8a5 From 43f1a1dd1b44065663a797603012394c52a9baea Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 13 May 2023 13:31:58 -0300 Subject: use ESM module syntax & update tui-lib Exciting update! This doesn't make any substantial changes exactly but does update the most quickly-archaic parts of older Node code. --- .eslintrc | 24 + backend.js | 50 +- client.js | 36 +- combine-album.js | 15 +- crawlers.js | 46 +- downloaders.js | 94 +-- general-util.js | 48 +- guess.js | 24 +- index.js | 43 +- metadata-readers.js | 23 +- package-lock.json | 1975 ++++++++++++++++++++++++++++++++++++++++++++++++++- package.json | 11 +- players.js | 56 +- playlist-utils.js | 137 ++-- record-store.js | 2 +- smart-playlist.js | 8 +- socat.js | 18 +- telnet.js | 23 +- todo.txt | 6 + ui.js | 131 ++-- undo-manager.js | 4 +- 21 files changed, 2278 insertions(+), 496 deletions(-) create mode 100644 .eslintrc diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..f742bb8 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,24 @@ +{ + "env": { + "es2021": true, + "node": true + }, + "extends": "eslint:recommended", + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "rules": { + "indent": ["off"], + "no-constant-condition": ["error", { + "checkLoops": false + }], + "no-empty": ["error", { + "allowEmptyCatch": true + }], + "no-unused-vars": ["error", { + "argsIgnorePattern": "^_", + "destructuredArrayIgnorePattern": "^" + }] + } +} diff --git a/backend.js b/backend.js index 59c4a48..4142026 100644 --- a/backend.js +++ b/backend.js @@ -3,31 +3,27 @@ 'use strict' -const { getDownloaderFor } = require('./downloaders') -const { getMetadataReaderFor } = require('./metadata-readers') -const { getPlayer } = require('./players') -const RecordStore = require('./record-store') -const os = require('os') +import {readFile, writeFile} from 'node:fs/promises' +import EventEmitter from 'node:events' +import os from 'node:os' -const { +import {getDownloaderFor} from './downloaders.js' +import {getMetadataReaderFor} from './metadata-readers.js' +import {getPlayer} from './players.js' +import RecordStore from './record-store.js' + +import { getTimeStringsFromSec, shuffleArray, - throttlePromise -} = require('./general-util') + throttlePromise, +} from './general-util.js' -const { +import { isGroup, isTrack, flattenGrouplike, - getItemPathString, - parentSymbol -} = require('./playlist-utils') - -const { promisify } = require('util') -const EventEmitter = require('events') -const fs = require('fs') -const writeFile = promisify(fs.writeFile) -const readFile = promisify(fs.readFile) + parentSymbol, +} from './playlist-utils.js' async function download(item, record) { if (isGroup(item)) { @@ -206,20 +202,6 @@ class QueuePlayer extends EventEmitter { const distributeSize = distributeEnd - distributeStart - const queueItem = (item, insertIndex) => { - if (items.includes(item)) { - /* - if (!movePlayingTrack && item === this.playingTrack) { - return - } - */ - items.splice(items.indexOf(item), 1) - } else { - offset++ - } - items.splice(insertIndex, 0, item) - } - if (how === 'evenly') { let offset = 0 for (const item of newTracks) { @@ -647,7 +629,7 @@ class QueuePlayer extends EventEmitter { } } -class Backend extends EventEmitter { +export default class Backend extends EventEmitter { constructor({ playerName = null, playerOptions = [] @@ -830,5 +812,3 @@ class Backend extends EventEmitter { return download(item, this.getRecordFor(item)) } } - -module.exports = Backend diff --git a/client.js b/client.js index ec1ab60..0af45f6 100644 --- a/client.js +++ b/client.js @@ -1,22 +1,18 @@ // Generic code for setting up mtui and the UI for any command line client. -'use strict' - -const AppElement = require('./ui') -const processSmartPlaylist = require('./smart-playlist') - -const { - ui: { - Root - }, - util: { - ansi, - Flushable, - TelnetInterfacer - } -} = require('tui-lib') +import AppElement from './ui.js' + +import {Root} from 'tui-lib/ui/primitives' -const setupClient = async ({backend, writable, interfacer, appConfig}) => { +import {Flushable} from 'tui-lib/util/interfaces' +import * as ansi from 'tui-lib/util/ansi' + +export default async function setupClient({ + backend, + writable, + screenInterface, + appConfig, +}) { const cleanTerminal = () => { writable.write(ansi.cleanCursor()) writable.write(ansi.disableAlternateScreen()) @@ -30,10 +26,10 @@ const setupClient = async ({backend, writable, interfacer, appConfig}) => { dirtyTerminal() const flushable = new Flushable(writable, true) - const root = new Root(interfacer, flushable) + const root = new Root(screenInterface, flushable) root.on('rendered', () => flushable.flush()) - const size = await interfacer.getScreenSize() + const size = await screenInterface.getScreenSize() root.w = size.width root.h = size.height root.fixAllLayout() @@ -42,7 +38,7 @@ const setupClient = async ({backend, writable, interfacer, appConfig}) => { flushable.write(ansi.clearScreen()) flushable.flush() - interfacer.on('resize', newSize => { + screenInterface.on('resize', newSize => { root.w = newSize.width root.h = newSize.height flushable.resizeScreen(newSize) @@ -69,5 +65,3 @@ const setupClient = async ({backend, writable, interfacer, appConfig}) => { return {appElement, cleanTerminal, dirtyTerminal, flushable, root} } - -module.exports = setupClient diff --git a/combine-album.js b/combine-album.js index 946c4c1..3b57b6c 100644 --- a/combine-album.js +++ b/combine-album.js @@ -1,12 +1,13 @@ 'use strict' -// too lazy to use import syntax :) -const { readdir, readFile, stat, writeFile } = require('fs/promises') -const { spawn } = require('child_process') -const { getTimeStringsFromSec, parseOptions, promisifyProcess } = require('./general-util') -const { musicExtensions } = require('./crawlers') -const path = require('path') -const shellescape = require('shell-escape') +import {readdir, readFile, stat, writeFile} from 'node:fs/promises' +import {spawn} from 'node:child_process' +import path from 'node:path' + +import shellescape from 'shell-escape' + +import {musicExtensions} from './crawlers.js' +import {getTimeStringsFromSec, parseOptions, promisifyProcess} from './general-util.js' async function timestamps(files) { const tsData = [] diff --git a/crawlers.js b/crawlers.js index 6af615d..b2f13fd 100644 --- a/crawlers.js +++ b/crawlers.js @@ -1,25 +1,21 @@ -const fs = require('fs') -const path = require('path') -const expandHomeDir = require('expand-home-dir') -const fetch = require('node-fetch') -const url = require('url') -const { downloadPlaylistFromOptionValue, promisifyProcess } = require('./general-util') -const { spawn } = require('child_process') -const { orderBy } = require('natural-orderby') - -const { promisify } = require('util') -const readDir = promisify(fs.readdir) -const stat = promisify(fs.stat) - -const musicExtensions = [ +import {spawn} from 'node:child_process' +import {readdir, stat} from 'node:fs/promises' +import url from 'node:url' +import path from 'node:path' + +import {orderBy} from 'natural-orderby' +import expandHomeDir from 'expand-home-dir' +// import fetch from 'node-fetch' + +import {downloadPlaylistFromOptionValue, promisifyProcess} from './general-util.js' + +export const musicExtensions = [ 'ogg', 'oga', 'wav', 'mp3', 'm4a', 'aac', 'flac', 'opus', 'mp4', 'mov', 'mkv', 'mod' ] -module.exports.musicExtensions = musicExtensions - // Each value is a function with these additional properties: // * crawlerName: The name of the crawler, such as "crawl-http". Used by // getCrawlerByName. @@ -30,7 +26,7 @@ module.exports.musicExtensions = musicExtensions const allCrawlers = {} /* TODO: Removed cheerio, so crawl-http no longer works. -function crawlHTTP(absURL, opts = {}, internals = {}) { +export function crawlHTTP(absURL, opts = {}, internals = {}) { // Recursively crawls a given URL, following every link to a deeper path and // recording all links in a tree (in the same format playlists use). Makes // multiple attempts to download failed paths. @@ -251,7 +247,7 @@ function crawlLocal(dirPath, extensions = musicExtensions, isTop = true) { dirPath = expandHomeDir(dirPath) } - return readDir(dirPath).then(items => { + return readdir(dirPath).then(items => { items = orderBy(items) return Promise.all(items.map(item => { @@ -278,7 +274,7 @@ function crawlLocal(dirPath, extensions = musicExtensions, isTop = true) { return {name: item, url: itemURL} } } - }, statErr => null) + }, _statErr => null) })) }, err => { if (err.code === 'ENOENT') { @@ -325,7 +321,7 @@ crawlLocal.isAppropriateForArg = function(arg) { allCrawlers.crawlLocal = crawlLocal -async function crawlYouTube(url) { +export async function crawlYouTube(url) { const ytdl = spawn('youtube-dl', [ '-j', // Output as JSON '--flat-playlist', @@ -385,7 +381,7 @@ crawlYouTube.isAppropriateForArg = function(arg) { allCrawlers.crawlYouTube = crawlYouTube -async function openFile(input) { +export async function openFile(input) { return JSON.parse(await downloadPlaylistFromOptionValue(input)) } @@ -398,14 +394,10 @@ openFile.isAppropriateForArg = function(arg) { allCrawlers.openFile = openFile -// Actual module.exports stuff: - -Object.assign(module.exports, allCrawlers) - -module.exports.getCrawlerByName = function(name) { +export function getCrawlerByName(name) { return Object.values(allCrawlers).find(fn => fn.crawlerName === name) } -module.exports.getAllCrawlersForArg = function(arg) { +export function getAllCrawlersForArg(arg) { return Object.values(allCrawlers).filter(fn => fn.isAppropriateForArg(arg)) } diff --git a/downloaders.js b/downloaders.js index 941c805..9e7c786 100644 --- a/downloaders.js +++ b/downloaders.js @@ -1,25 +1,21 @@ -const { promisifyProcess } = require('./general-util') -const { promisify } = require('util') -const { spawn } = require('child_process') -const { URL } = require('url') -const mkdirp = promisify(require('mkdirp')) -const fs = require('fs') -const fetch = require('node-fetch') -const tempy = require('tempy') -const os = require('os') -const path = require('path') -const sanitize = require('sanitize-filename') - -const writeFile = promisify(fs.writeFile) -const rename = promisify(fs.rename) -const stat = promisify(fs.stat) -const readdir = promisify(fs.readdir) -const symlink = promisify(fs.symlink) +import {spawn} from 'node:child_process' +import {createReadStream, createWriteStream} from 'node:fs' +import {readdir, rename, stat, symlink, writeFile} from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import url from 'node:url' + +import {mkdirp} from 'mkdirp' +import fetch from 'node-fetch' +import sanitize from 'sanitize-filename' +import tempy from 'tempy' + +import {promisifyProcess} from './general-util.js' const copyFile = (source, target) => { // Stolen from https://stackoverflow.com/a/30405105/4633828 - const rd = fs.createReadStream(source) - const wr = fs.createWriteStream(target) + const rd = createReadStream(source) + const wr = createWriteStream(target) return new Promise((resolve, reject) => { rd.on('error', reject) wr.on('error', reject) @@ -32,7 +28,7 @@ const copyFile = (source, target) => { }) } -// const disableBackResolving = arg => arg.split('/').map(str => str.replace(/^\../, '_..')).join('/') +export const rootCacheDir = path.join(os.homedir(), '.mtui', 'downloads') const cachify = (identifier, keyFunction, baseFunction) => { return async arg => { @@ -43,7 +39,7 @@ const cachify = (identifier, keyFunction, baseFunction) => { // Determine where the final file will end up. This is just a directory - // the file's own name is determined by the downloader. - const cacheDir = downloaders.rootCacheDir + '/' + identifier + const cacheDir = rootCacheDir + '/' + identifier const finalDirectory = cacheDir + '/' + sanitize(keyFunction(arg)) // Check if that directory only exists. If it does, return the file in it, @@ -102,15 +98,16 @@ const removeFileProtocol = arg => { } } -const downloaders = { - extension: 'mp3', // Generally target file extension, used by youtube-dl +// Generally target file extension, used by youtube-dl +export const extension = 'mp3' - rootCacheDir: os.homedir() + '/.mtui/downloads', +const downloaders = {} - http: cachify('http', +downloaders.http = + cachify('http', arg => { - const url = new URL(arg) - return url.hostname + url.pathname + const {hostname, pathname} = new url.URL(arg) + return hostname + pathname }, arg => { const out = ( @@ -121,9 +118,10 @@ const downloaders = { .then(response => response.buffer()) .then(buffer => writeFile(out, buffer)) .then(() => out) - }), + }) - youtubedl: cachify('youtubedl', +downloaders.youtubedl = + cachify('youtubedl', arg => (arg.match(/watch\?v=(.*)/) || ['', arg])[1], arg => { const outDir = tempy.directory() @@ -133,7 +131,7 @@ const downloaders = { '--quiet', '--no-warnings', '--extract-audio', - '--audio-format', downloaders.extension, + '--audio-format', extension, '--output', outFile, arg ] @@ -141,9 +139,10 @@ const downloaders = { return promisifyProcess(spawn('youtube-dl', opts)) .then(() => readdir(outDir)) .then(files => outDir + '/' + files[0]) - }), + }) - local: cachify('local', +downloaders.local = + cachify('local', arg => arg, arg => { // Usually we'd just return the given argument in a local @@ -171,9 +170,10 @@ const downloaders = { return copyFile(arg, out) .then(() => out) - }), + }) - locallink: cachify('locallink', +downloaders.locallink = + cachify('locallink', arg => arg, arg => { // Like the local downloader, but creates a symbolic link to the argument. @@ -184,22 +184,22 @@ const downloaders = { return symlink(path.resolve(arg), out) .then(() => out) - }), + }) - echo: arg => arg, +downloaders.echo = + arg => arg - getDownloaderFor: arg => { - if (arg.startsWith('http://') || arg.startsWith('https://')) { - if (arg.includes('youtube.com')) { - return downloaders.youtubedl - } else { - return downloaders.http - } +export default downloaders + +export function getDownloaderFor(arg) { + if (arg.startsWith('http://') || arg.startsWith('https://')) { + if (arg.includes('youtube.com')) { + return downloaders.youtubedl } else { - // return downloaders.local - return downloaders.locallink + return downloaders.http } + } else { + // return downloaders.local + return downloaders.locallink } } - -module.exports = downloaders diff --git a/general-util.js b/general-util.js index aba1541..bb0574a 100644 --- a/general-util.js +++ b/general-util.js @@ -1,13 +1,11 @@ -const { spawn } = require('child_process') -const { promisify } = require('util') -const fetch = require('node-fetch') -const fs = require('fs') -const npmCommandExists = require('command-exists') -const url = require('url') +import {spawn} from 'node:child_process' +import {readFile} from 'node:fs/promises' +import {fileURLToPath, URL} from 'node:url' -const readFile = promisify(fs.readFile) +import npmCommandExists from 'command-exists' +import fetch from 'node-fetch' -module.exports.promisifyProcess = function(proc, showLogging = true) { +export function promisifyProcess(proc, showLogging = true) { // Takes a process (from the child_process module) and returns a promise // that resolves when the process exits (or rejects, if the exit code is // non-zero). @@ -28,7 +26,7 @@ module.exports.promisifyProcess = function(proc, showLogging = true) { }) } -module.exports.commandExists = async function(command) { +export async function commandExists(command) { // When the command-exists module sees that a given command doesn't exist, it // throws an error instead of returning false, which is not what we want. @@ -39,12 +37,12 @@ module.exports.commandExists = async function(command) { } } -module.exports.killProcess = async function(proc) { +export async function killProcess(proc) { // Windows is stupid and doesn't like it when we try to kill processes. // So instead we use taskkill! https://stackoverflow.com/a/28163919/4633828 - if (await module.exports.commandExists('taskkill')) { - await module.exports.promisifyProcess( + if (await commandExists('taskkill')) { + await promisifyProcess( spawn('taskkill', ['/pid', proc.pid, '/f', '/t']), false ) @@ -53,18 +51,18 @@ module.exports.killProcess = async function(proc) { } } -function downloadPlaylistFromURL(url) { +export function downloadPlaylistFromURL(url) { return fetch(url).then(res => res.text()) } -function downloadPlaylistFromLocalPath(path) { +export function downloadPlaylistFromLocalPath(path) { return readFile(path).then(buf => buf.toString()) } -module.exports.downloadPlaylistFromOptionValue = function(arg) { +export function downloadPlaylistFromOptionValue(arg) { let argURL try { - argURL = new url.URL(arg) + argURL = new URL(arg) } catch (err) { // Definitely not a URL. } @@ -73,14 +71,14 @@ module.exports.downloadPlaylistFromOptionValue = function(arg) { if (argURL.protocol === 'http:' || argURL.protocol === 'https:') { return downloadPlaylistFromURL(arg) } else if (argURL.protocol === 'file:') { - return downloadPlaylistFromLocalPath(url.fileURLToPath(argURL)) + return downloadPlaylistFromLocalPath(fileURLToPath(argURL)) } } else { return downloadPlaylistFromLocalPath(arg) } } -module.exports.shuffleArray = function(array) { +export function shuffleArray(array) { // Shuffles the items in an array. Returns a new array (does not modify the // passed array). Super-interesting post on how this algorithm works: // https://bost.ocks.org/mike/shuffle/ @@ -103,7 +101,7 @@ module.exports.shuffleArray = function(array) { return workingArray } -module.exports.throttlePromise = function(maximumAtOneTime = 10) { +export function throttlePromise(maximumAtOneTime = 10) { // Returns a function that takes a callback to create a promise and either // runs it now, if there is an available slot, or enqueues it to be run // later, if there is not. @@ -139,7 +137,7 @@ module.exports.throttlePromise = function(maximumAtOneTime = 10) { return enqueue } -module.exports.getSecFromTimestamp = function(timestamp) { +export function getSecFromTimestamp(timestamp) { const parts = timestamp.split(':').map(n => parseInt(n)) switch (parts.length) { case 3: return parts[0] * 3600 + parts[1] * 60 + parts[2] @@ -149,7 +147,7 @@ module.exports.getSecFromTimestamp = function(timestamp) { } } -module.exports.getTimeStringsFromSec = function(curSecTotal, lenSecTotal, fraction = false) { +export function getTimeStringsFromSec(curSecTotal, lenSecTotal, fraction = false) { const percentVal = (100 / lenSecTotal) * curSecTotal const percentDone = ( (Math.trunc(percentVal * 100) / 100).toFixed(2) + '%' @@ -207,16 +205,16 @@ module.exports.getTimeStringsFromSec = function(curSecTotal, lenSecTotal, fracti return {percentDone, timeDone, timeLeft, duration, curSecTotal, lenSecTotal} } -module.exports.getTimeStrings = function({curHour, curMin, curSec, lenHour, lenMin, lenSec}) { +export function getTimeStrings({curHour, curMin, curSec, lenHour, lenMin, lenSec}) { // Multiplication casts to numbers; addition prioritizes strings. // Thanks, JavaScript! const curSecTotal = (3600 * curHour) + (60 * curMin) + (1 * curSec) const lenSecTotal = (3600 * lenHour) + (60 * lenMin) + (1 * lenSec) - return module.exports.getTimeStringsFromSec(curSecTotal, lenSecTotal) + return getTimeStringsFromSec(curSecTotal, lenSecTotal) } -const parseOptions = async function(options, optionDescriptorMap) { +export async function parseOptions(options, optionDescriptorMap) { // This function is sorely lacking in comments, but the basic usage is // as such: // @@ -331,5 +329,3 @@ const parseOptions = async function(options, optionDescriptorMap) { } parseOptions.handleDashless = Symbol() - -module.exports.parseOptions = parseOptions diff --git a/guess.js b/guess.js index db9f8e8..3c72f64 100644 --- a/guess.js +++ b/guess.js @@ -1,21 +1,13 @@ 'use strict' -const Backend = require('./backend') -const os = require('os') -const processSmartPlaylist = require('./smart-playlist') - -const { - flattenGrouplike, - parentSymbol, - searchForItem -} = require('./playlist-utils') - -const { - util: { - ansi, - telchars: telc - } -} = require('tui-lib') +import os from 'node:os' + +import * as ansi from 'tui-lib/util/ansi' +import telc from 'tui-lib/util/telchars' + +import {flattenGrouplike, parentSymbol, searchForItem} from './playlist-utils.js' +import processSmartPlaylist from './smart-playlist.js' +import Backend from './backend.js' function untilEvent(object, event) { return new Promise(resolve => { diff --git a/index.js b/index.js index b320812..a5930bc 100755 --- a/index.js +++ b/index.js @@ -2,36 +2,18 @@ // omg I am tired of code -const { getAllCrawlersForArg } = require('./crawlers') -const { getPlayer } = require('./players') -const { parseOptions } = require('./general-util') -const AppElement = require('./ui') -const Backend = require('./backend') -const TelnetServer = require('./telnet') -const processSmartPlaylist = require('./smart-playlist') -const setupClient = require('./client') - -const { - getItemPathString, - updatePlaylistFormat -} = require('./playlist-utils') - -const { - ui: { - Root - }, - util: { - ansi, - CommandLineInterfacer, - Flushable - } -} = require('tui-lib') +import {getPlayer} from './players.js' +import {parseOptions} from './general-util.js' +import {getItemPathString} from './playlist-utils.js' +import Backend from './backend.js' +import setupClient from './client.js' +import TelnetServer from './telnet.js' + +import {CommandLineInterface} from 'tui-lib/util/interfaces' +import * as ansi from 'tui-lib/util/ansi' -const { promisify } = require('util') -const fs = require('fs') -const os = require('os') -const readFile = promisify(fs.readFile) -const writeFile = promisify(fs.writeFile) +import {writeFile} from 'node:fs/promises' +import os from 'node:os' // Hack to get around errors when piping many things to stdout/err // (from general-util promisifyProcess) @@ -101,7 +83,7 @@ async function main() { const { appElement, dirtyTerminal, flushable, root } = await setupClient({ backend, - interfacer: new CommandLineInterfacer(), + screenInterface: new CommandLineInterface(), writable: process.stdout }) @@ -159,6 +141,7 @@ async function main() { root.h = h root.fixAllLayout() + /* eslint-disable-next-line no-unused-vars */ const XXstress = func => '[disabled]' const stress = func => { diff --git a/metadata-readers.js b/metadata-readers.js index edcac72..d0f5f55 100644 --- a/metadata-readers.js +++ b/metadata-readers.js @@ -1,5 +1,6 @@ -const { promisifyProcess } = require('./general-util') -const { spawn } = require('child_process') +import {spawn} from 'node:child_process' + +import {promisifyProcess} from './general-util.js' // Some probers are sorta inconsistent; this function lets them try again if // they fail the first time. @@ -21,8 +22,10 @@ const tryAgain = function(times, func) { } } -const metadataReaders = { - ffprobe: tryAgain(6, async filePath => { +const metadataReaders = {} + +metadataReaders.ffprobe = + tryAgain(6, async filePath => { const ffprobe = spawn('ffprobe', [ '-print_format', 'json', '-show_entries', 'stream=codec_name:format', @@ -60,11 +63,11 @@ const metadataReaders = { fileSize: parseInt(data.format.size), bitrate: parseInt(data.format.bit_rate) } - }), + }) - getMetadataReaderFor: arg => { - return metadataReaders.ffprobe - } -} +export default metadataReaders -module.exports = metadataReaders +export function getMetadataReaderFor(_arg) { + // Only the one metadata reader implemented, so far! + return metadataReaders.ffprobe +} diff --git a/package-lock.json b/package-lock.json index c3f88fe..7870e28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,19 +11,253 @@ "dependencies": { "command-exists": "^1.2.9", "expand-home-dir": "0.0.3", - "mkdirp": "^0.5.5", + "mkdirp": "^3.0.1", "natural-orderby": "^2.0.3", "node-fetch": "^2.6.0", "open": "^7.0.3", "sanitize-filename": "^1.6.3", "shell-escape": "^0.2.0", "tempy": "^0.2.1", - "tui-lib": "^0.3.2", - "tui-text-editor": "^0.3.1", - "word-wrap": "^1.2.3" + "tui-lib": "^0.4.0", + "tui-text-editor": "^0.3.1" }, "bin": { "mtui": "index.js" + }, + "devDependencies": { + "eslint": "^8.40.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.1.tgz", + "integrity": "sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.3.tgz", + "integrity": "sha512-+5gy6OQfk+xx3q0d6jGZZC3f3KzAkXc/IanVxd1is/VIIziRqqt3ongQz0FiTUXqTk0c7aDB3OaFuKnuSoJicQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.5.2", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.40.0.tgz", + "integrity": "sha512-ElyB54bJIhXQYVKjDSvCkPO1iU1tSAeVQJbllWJq1XQSmmA4dgFk8CbiBGpiOPxleE48vDogxCtmMYku4HSVLA==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.8", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", + "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^1.2.1", + "debug": "^4.1.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "dev": true + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/acorn": { + "version": "8.8.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", + "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, "node_modules/clone": { @@ -34,11 +268,49 @@ "node": ">=0.8" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, "node_modules/command-exists": { "version": "1.2.9", "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==" }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/crypto-random-string": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz", @@ -47,6 +319,29 @@ "node": ">=4" } }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, "node_modules/defaults": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", @@ -55,11 +350,371 @@ "clone": "^1.0.2" } }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.40.0.tgz", + "integrity": "sha512-bvR+TsP9EHL3TqNtj9sCNJVAFK3fBN8Q7g5waghxyRsPLIMwL73XSKnZFK0hk/O2ANC+iAoq6PWMQ+IfBAJIiQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.4.0", + "@eslint/eslintrc": "^2.0.3", + "@eslint/js": "8.40.0", + "@humanwhocodes/config-array": "^0.11.8", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "ajv": "^6.10.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.0", + "eslint-visitor-keys": "^3.4.1", + "espree": "^9.5.2", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "grapheme-splitter": "^1.0.4", + "ignore": "^5.2.0", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-sdsl": "^4.1.4", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.1", + "strip-ansi": "^6.0.1", + "strip-json-comments": "^3.1.0", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.0.tgz", + "integrity": "sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz", + "integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "9.5.2", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.2.tgz", + "integrity": "sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==", + "dev": true, + "dependencies": { + "acorn": "^8.8.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/expand-home-dir": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/expand-home-dir/-/expand-home-dir-0.0.3.tgz", "integrity": "sha1-ct6KBIbMKKO71wRjU5iCW1tign0=" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", + "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", + "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "dev": true, + "dependencies": { + "flatted": "^3.1.0", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", + "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", + "dev": true + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.20.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", + "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/grapheme-splitter": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", + "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", + "dev": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", + "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, "node_modules/is-docker": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", @@ -74,6 +729,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -85,22 +770,118 @@ "node": ">=8" } }, - "node_modules/minimist": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", - "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "node_modules/js-sdsl": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz", + "integrity": "sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "dependencies": { - "minimist": "^1.2.6" + "brace-expansion": "^1.1.7" }, + "engines": { + "node": "*" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", "bin": { - "mkdirp": "bin/cmd.js" + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, "node_modules/natural-orderby": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-2.0.3.tgz", @@ -128,6 +909,15 @@ } } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, "node_modules/open": { "version": "7.4.2", "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", @@ -143,19 +933,257 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/sanitize-filename": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", - "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "node_modules/optionator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", + "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/sanitize-filename": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", + "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-escape": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz", + "integrity": "sha1-aP0CXrBJC09WegJ/C/IkgLX4QTM=" + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { - "truncate-utf8-bytes": "^1.0.0" + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/shell-escape": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz", - "integrity": "sha1-aP0CXrBJC09WegJ/C/IkgLX4QTM=" - }, "node_modules/temp-dir": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", @@ -176,6 +1204,12 @@ "node": ">=4" } }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -190,13 +1224,22 @@ } }, "node_modules/tui-lib": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.3.3.tgz", - "integrity": "sha512-Cgnzpv3tl4il72spmfDQRCwEjGm2VoS8NgtOEwtFAFVj8k+gfXpIxwok3LW8Ik/vEG9qa0N1tABXf2MEzCTmhQ==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.4.0.tgz", + "integrity": "sha512-P7PgQHWNK8yVlWZbWm7XLFwirkzQzKNYkhle2YYzj1Ba7fDuh5CITDLvogKFmZSC7RiBC4Y2+2uBpNcRAf1gwQ==", "dependencies": { + "natural-orderby": "^3.0.2", "wcwidth": "^1.0.1" } }, + "node_modules/tui-lib/node_modules/natural-orderby": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-3.0.2.tgz", + "integrity": "sha512-x7ZdOwBxZCEm9MM7+eQCjkrNLrW3rkBKNHVr78zbtqnMGVNlnDi6C/eUEYgxHNrcbu0ymvjzcwIL/6H1iHri9g==", + "engines": { + "node": ">=18" + } + }, "node_modules/tui-text-editor": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/tui-text-editor/-/tui-text-editor-0.3.1.tgz", @@ -214,6 +1257,30 @@ "word-wrap": "^1.2.3" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unique-string": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz", @@ -225,6 +1292,15 @@ "node": ">=4" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz", @@ -252,6 +1328,21 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", @@ -259,24 +1350,254 @@ "engines": { "node": ">=0.10.0" } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } }, "dependencies": { + "@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^3.3.0" + } + }, + "@eslint-community/regexpp": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.1.tgz", + "integrity": "sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==", + "dev": true + }, + "@eslint/eslintrc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.3.tgz", + "integrity": "sha512-+5gy6OQfk+xx3q0d6jGZZC3f3KzAkXc/IanVxd1is/VIIziRqqt3ongQz0FiTUXqTk0c7aDB3OaFuKnuSoJicQ==", + "dev": true, + "requires": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.5.2", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + } + }, + "@eslint/js": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.40.0.tgz", + "integrity": "sha512-ElyB54bJIhXQYVKjDSvCkPO1iU1tSAeVQJbllWJq1XQSmmA4dgFk8CbiBGpiOPxleE48vDogxCtmMYku4HSVLA==", + "dev": true + }, + "@humanwhocodes/config-array": { + "version": "0.11.8", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", + "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==", + "dev": true, + "requires": { + "@humanwhocodes/object-schema": "^1.2.1", + "debug": "^4.1.1", + "minimatch": "^3.0.5" + } + }, + "@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true + }, + "@humanwhocodes/object-schema": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "dev": true + }, + "@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + } + }, + "@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true + }, + "@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "requires": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + } + }, + "acorn": { + "version": "8.8.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", + "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "dev": true + }, + "acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "requires": {} + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, "clone": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=" }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, "command-exists": { "version": "1.2.9", "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==" }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, "crypto-random-string": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz", "integrity": "sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=" }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, "defaults": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", @@ -285,16 +1606,307 @@ "clone": "^1.0.2" } }, + "doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, + "eslint": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.40.0.tgz", + "integrity": "sha512-bvR+TsP9EHL3TqNtj9sCNJVAFK3fBN8Q7g5waghxyRsPLIMwL73XSKnZFK0hk/O2ANC+iAoq6PWMQ+IfBAJIiQ==", + "dev": true, + "requires": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.4.0", + "@eslint/eslintrc": "^2.0.3", + "@eslint/js": "8.40.0", + "@humanwhocodes/config-array": "^0.11.8", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "ajv": "^6.10.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.0", + "eslint-visitor-keys": "^3.4.1", + "espree": "^9.5.2", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "grapheme-splitter": "^1.0.4", + "ignore": "^5.2.0", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-sdsl": "^4.1.4", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.1", + "strip-ansi": "^6.0.1", + "strip-json-comments": "^3.1.0", + "text-table": "^0.2.0" + } + }, + "eslint-scope": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.0.tgz", + "integrity": "sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + } + }, + "eslint-visitor-keys": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz", + "integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==", + "dev": true + }, + "espree": { + "version": "9.5.2", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.2.tgz", + "integrity": "sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==", + "dev": true, + "requires": { + "acorn": "^8.8.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + } + }, + "esquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, + "requires": { + "estraverse": "^5.1.0" + } + }, + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "requires": { + "estraverse": "^5.2.0" + } + }, + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, "expand-home-dir": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/expand-home-dir/-/expand-home-dir-0.0.3.tgz", "integrity": "sha1-ct6KBIbMKKO71wRjU5iCW1tign0=" }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "fastq": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", + "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "dev": true, + "requires": { + "reusify": "^1.0.4" + } + }, + "file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "requires": { + "flat-cache": "^3.0.4" + } + }, + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "flat-cache": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", + "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "dev": true, + "requires": { + "flatted": "^3.1.0", + "rimraf": "^3.0.2" + } + }, + "flatted": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", + "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", + "dev": true + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "requires": { + "is-glob": "^4.0.3" + } + }, + "globals": { + "version": "13.20.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", + "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", + "dev": true, + "requires": { + "type-fest": "^0.20.2" + } + }, + "grapheme-splitter": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", + "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "ignore": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", + "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "dev": true + }, + "import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, "is-docker": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==" }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true + }, "is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -303,19 +1915,90 @@ "is-docker": "^2.0.0" } }, - "minimist": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", - "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true }, - "mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "js-sdsl": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz", + "integrity": "sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==", + "dev": true + }, + "js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + } + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "requires": { - "minimist": "^1.2.6" + "brace-expansion": "^1.1.7" } }, + "mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==" + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, "natural-orderby": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-2.0.3.tgz", @@ -329,6 +2012,15 @@ "whatwg-url": "^5.0.0" } }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "requires": { + "wrappy": "1" + } + }, "open": { "version": "7.4.2", "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", @@ -338,6 +2030,113 @@ "is-wsl": "^2.1.1" } }, + "optionator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", + "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "dev": true, + "requires": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.3" + } + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "requires": { + "callsites": "^3.0.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true + }, + "punycode": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "dev": true + }, + "queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + }, + "reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "requires": { + "queue-microtask": "^1.2.2" + } + }, "sanitize-filename": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", @@ -346,11 +2145,50 @@ "truncate-utf8-bytes": "^1.0.0" } }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, "shell-escape": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz", "integrity": "sha1-aP0CXrBJC09WegJ/C/IkgLX4QTM=" }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, "temp-dir": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", @@ -365,6 +2203,12 @@ "unique-string": "^1.0.0" } }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, "tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -379,11 +2223,19 @@ } }, "tui-lib": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.3.3.tgz", - "integrity": "sha512-Cgnzpv3tl4il72spmfDQRCwEjGm2VoS8NgtOEwtFAFVj8k+gfXpIxwok3LW8Ik/vEG9qa0N1tABXf2MEzCTmhQ==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.4.0.tgz", + "integrity": "sha512-P7PgQHWNK8yVlWZbWm7XLFwirkzQzKNYkhle2YYzj1Ba7fDuh5CITDLvogKFmZSC7RiBC4Y2+2uBpNcRAf1gwQ==", "requires": { + "natural-orderby": "^3.0.2", "wcwidth": "^1.0.1" + }, + "dependencies": { + "natural-orderby": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-3.0.2.tgz", + "integrity": "sha512-x7ZdOwBxZCEm9MM7+eQCjkrNLrW3rkBKNHVr78zbtqnMGVNlnDi6C/eUEYgxHNrcbu0ymvjzcwIL/6H1iHri9g==" + } } }, "tui-text-editor": { @@ -405,6 +2257,21 @@ } } }, + "type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1" + } + }, + "type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true + }, "unique-string": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz", @@ -413,6 +2280,15 @@ "crypto-random-string": "^1.0.0" } }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, "utf8-byte-length": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz", @@ -440,10 +2316,31 @@ "webidl-conversions": "^3.0.0" } }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, "word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==" + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true } } } diff --git a/package.json b/package.json index a5777eb..3639c69 100644 --- a/package.json +++ b/package.json @@ -8,18 +8,21 @@ }, "author": "", "license": "GPL-3.0", + "type": "module", "dependencies": { "command-exists": "^1.2.9", "expand-home-dir": "0.0.3", - "mkdirp": "^0.5.5", + "mkdirp": "^3.0.1", "natural-orderby": "^2.0.3", "node-fetch": "^2.6.0", "open": "^7.0.3", "sanitize-filename": "^1.6.3", "shell-escape": "^0.2.0", "tempy": "^0.2.1", - "tui-lib": "^0.3.2", - "tui-text-editor": "^0.3.1", - "word-wrap": "^1.2.3" + "tui-lib": "^0.4.0", + "tui-text-editor": "^0.3.1" + }, + "devDependencies": { + "eslint": "^8.40.0" } } diff --git a/players.js b/players.js index 1d64061..959bf27 100644 --- a/players.js +++ b/players.js @@ -1,21 +1,22 @@ // stolen from http-music -const { +import { commandExists, killProcess, getTimeStrings, - getTimeStringsFromSec -} = require('./general-util') + getTimeStringsFromSec, +} from './general-util.js' -const { spawn } = require('child_process') -const EventEmitter = require('events') -const Socat = require('./socat') -const fs = require('fs') -const util = require('util') +import {spawn} from 'node:child_process' +import {statSync} from 'node:fs' +import {unlink} from 'node:fs/promises' +import EventEmitter from 'node:events' +import path from 'node:path' +import url from 'node:url' -const unlink = util.promisify(fs.unlink) +import Socat from './socat.js' -class Player extends EventEmitter { +export class Player extends EventEmitter { constructor(processOptions = []) { super() @@ -43,14 +44,14 @@ class Player extends EventEmitter { return this._process } - playFile(file, startTime) {} - seekAhead(secs) {} - seekBack(secs) {} - seekTo(timeInSecs) {} + playFile(_file, _startTime) {} + seekAhead(_secs) {} + seekBack(_secs) {} + seekTo(_timeInSecs) {} seekToStart() {} - volUp(amount) {} - volDown(amount) {} - setVolume(value) {} + volUp(_amount) {} + volDown(_amount) {} + setVolume(_value) {} updateVolume() {} togglePause() {} toggleLoop() {} @@ -93,7 +94,7 @@ class Player extends EventEmitter { } } -module.exports.MPVPlayer = class extends Player { +export class MPVPlayer extends Player { // 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. @@ -172,7 +173,7 @@ module.exports.MPVPlayer = class extends Player { } } -module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer { +export class ControllableMPVPlayer extends MPVPlayer { getMPVOptions(...args) { return ['--input-ipc-server=' + this.socat.path, ...super.getMPVOptions(...args)] } @@ -181,8 +182,7 @@ module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer { this.removeSocket(this.socketPath) do { - // this.socketPathpath = '/tmp/mtui-socket-' + Math.floor(Math.random() * 10000) - this.socketPath = __dirname + '/mtui-socket-' + Math.floor(Math.random() * 10000) + this.socketPath = path.join(path.dirname(url.fileURLToPath(import.meta.url)), 'mtui-socket-' + Math.floor(Math.random() * 10000)) } while (this.existsSync(this.socketPath)) this.socat = new Socat(this.socketPath) @@ -196,7 +196,7 @@ module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer { existsSync(path) { try { - fs.statSync(path) + statSync(path) return true } catch (error) { return false @@ -289,7 +289,7 @@ module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer { } } -module.exports.SoXPlayer = class extends Player { +export class SoXPlayer extends Player { 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). @@ -315,14 +315,12 @@ module.exports.SoXPlayer = class extends Player { return } - const timeRegex = '([0-9]*):([0-9]*):([0-9]*)\.([0-9]*)' + const timeRegex = String.raw`([0-9]*):([0-9]*):([0-9]*)\.([0-9]*)` const match = data.toString().trim().match(new RegExp( `^In:([0-9.]+%)\\s*${timeRegex}\\s*\\[${timeRegex}\\]` )) if (match) { - const percentStr = match[1] - // SoX takes a loooooot of math in order to actually figure out the // duration, since it outputs the current time and the remaining time // (but not the duration). @@ -385,15 +383,15 @@ module.exports.SoXPlayer = class extends Player { } } -module.exports.getPlayer = async function(name = null, options = []) { +export async function getPlayer(name = null, options = []) { if (await commandExists('mpv') && (name === null || name === 'mpv')) { - return new module.exports.ControllableMPVPlayer(options) + return new ControllableMPVPlayer(options) } else if (name === 'mpv') { return null } if (await commandExists('play') && (name === null || name === 'sox')) { - return new module.exports.SoXPlayer(options) + return new SoXPlayer(options) } else if (name === 'sox') { return null } diff --git a/playlist-utils.js b/playlist-utils.js index 227c985..0fe26df 100644 --- a/playlist-utils.js +++ b/playlist-utils.js @@ -1,16 +1,12 @@ 'use strict' -const path = require('path') -const fs = require('fs') +import path from 'node:path' -const { promisify } = require('util') -const unlink = promisify(fs.unlink) +import {shuffleArray} from './general-util.js' -const { shuffleArray } = require('./general-util') +export const parentSymbol = Symbol('Parent group') -const parentSymbol = Symbol('Parent group') - -function updatePlaylistFormat(playlist) { +export function updatePlaylistFormat(playlist) { const defaultPlaylist = { options: [], items: [] @@ -43,7 +39,7 @@ function updatePlaylistFormat(playlist) { return updateGroupFormat(fullPlaylistObj) } -function updateGroupFormat(group) { +export function updateGroupFormat(group) { const defaultGroup = { name: '', items: [] @@ -61,7 +57,7 @@ function updateGroupFormat(group) { groupObj.items = groupObj.items.map(item => { // Check if it's a group; if not, it's probably a track. - if (typeof item[1] === 'array' || item.items) { + if (Array.isArray(item[1]) || item.items) { item = updateGroupFormat(item) } else { item = updateTrackFormat(item) @@ -85,7 +81,7 @@ function updateGroupFormat(group) { return groupObj } -function updateTrackFormat(track) { +export function updateTrackFormat(track) { const defaultTrack = { name: '', downloaderArg: '' @@ -106,7 +102,7 @@ function updateTrackFormat(track) { return Object.assign(defaultTrack, trackObj) } -function cloneGrouplike(grouplike) { +export function cloneGrouplike(grouplike) { const newGrouplike = { name: grouplike.name, items: grouplike.items.map(item => { @@ -128,7 +124,7 @@ function cloneGrouplike(grouplike) { return newGrouplike } -function filterTracks(grouplike, handleTrack) { +export function filterTracks(grouplike, handleTrack) { // Recursively filters every track in the passed grouplike. The track-handler // function passed should either return true (to keep a track) or false (to // remove the track). After tracks are filtered, groups which contain no @@ -161,7 +157,7 @@ function filterTracks(grouplike, handleTrack) { }) } -function flattenGrouplike(grouplike) { +export function flattenGrouplike(grouplike) { // Flattens a group-like, taking all of the non-group items (tracks) at all // levels in the group tree and returns them as a new group containing those // tracks. @@ -177,7 +173,7 @@ function flattenGrouplike(grouplike) { } } -function countTotalTracks(item) { +export function countTotalTracks(item) { // Returns the total number of tracks in a grouplike, including tracks in any // descendant groups. Basically the same as flattenGrouplike().items.length. @@ -191,7 +187,7 @@ function countTotalTracks(item) { } } -function shuffleOrderOfGroups(grouplike) { +export function shuffleOrderOfGroups(grouplike) { // OK, this is opinionated on how it should work, but I think it Makes Sense. // Also sorry functional-programming friends, I'm sure this is a horror. // (FYI, this is the same as how http-music used to work with shuffle-groups, @@ -209,12 +205,12 @@ function shuffleOrderOfGroups(grouplike) { return {items: shuffleArray(items)} } -function reverseOrderOfGroups(grouplike) { +export function reverseOrderOfGroups(grouplike) { const { items } = collapseGrouplike(grouplike) return {items: items.reverse()} } -function collectGrouplikeChildren(grouplike, filter = null) { +export function collectGrouplikeChildren(grouplike, filter = null) { // Collects all descendants of a grouplike into a single flat array. // Can be passed a filter function, which will decide whether or not to add // an item to the return array. However, note that all descendants will be @@ -237,7 +233,7 @@ function collectGrouplikeChildren(grouplike, filter = null) { return items } -function partiallyFlattenGrouplike(grouplike, resultDepth) { +export function partiallyFlattenGrouplike(grouplike, resultDepth) { // Flattens a grouplike so that it is never more than a given number of // groups deep, INCLUDING the "top" group -- e.g. a resultDepth of 2 // means that there can be one level of groups remaining in the resulting @@ -258,7 +254,7 @@ function partiallyFlattenGrouplike(grouplike, resultDepth) { return {items} } -function collapseGrouplike(grouplike) { +export function collapseGrouplike(grouplike) { // Similar to partiallyFlattenGrouplike, but doesn't discard the individual // ordering of tracks; rather, it just collapses them all to one level. @@ -284,7 +280,7 @@ function collapseGrouplike(grouplike) { return {items: ret} } -function filterGrouplikeByProperty(grouplike, property, value) { +export function filterGrouplikeByProperty(grouplike, property, value) { // Returns a copy of the original grouplike, only keeping tracks with the // given property-value pair. (If the track's value for the given property // is an array, this will check if that array includes the given value.) @@ -314,13 +310,13 @@ function filterGrouplikeByProperty(grouplike, property, value) { }) } -function filterPlaylistByPathString(playlist, pathString) { +export function filterPlaylistByPathString(playlist, pathString) { // Calls filterGroupContentsByPath, taking an unparsed path string. return filterGrouplikeByPath(playlist, parsePathString(pathString)) } -function filterGrouplikeByPath(grouplike, pathParts) { +export function filterGrouplikeByPath(grouplike, pathParts) { // Finds a group by following the given group path and returns it. If the // function encounters an item in the group path that is not found, it logs // a warning message and returns the group found up to that point. If the @@ -371,13 +367,13 @@ function filterGrouplikeByPath(grouplike, pathParts) { } } -function removeGroupByPathString(playlist, pathString) { +export function removeGroupByPathString(playlist, pathString) { // Calls removeGroupByPath, taking a path string, rather than a parsed path. return removeGroupByPath(playlist, parsePathString(pathString)) } -function removeGroupByPath(playlist, pathParts) { +export function removeGroupByPath(playlist, pathParts) { // Removes the group at the given path from the given playlist. const groupToRemove = filterGrouplikeByPath(playlist, pathParts) @@ -418,7 +414,7 @@ function removeGroupByPath(playlist, pathParts) { } } -function getPlaylistTreeString(playlist, showTracks = false) { +export function getPlaylistTreeString(playlist, showTracks = false) { function recursive(group) { const groups = group.items.filter(x => isGroup(x)) const nonGroups = group.items.filter(x => !isGroup(x)) @@ -454,7 +450,7 @@ function getPlaylistTreeString(playlist, showTracks = false) { return recursive(playlist) } -function getItemPath(item) { +export function getItemPath(item) { if (item[parentSymbol]) { return [...getItemPath(item[parentSymbol]), item] } else { @@ -462,7 +458,7 @@ function getItemPath(item) { } } -function getItemPathString(item) { +export function getItemPathString(item) { // Gets the playlist path of an item by following its parent chain. // // Returns a string in format Foo/Bar/Baz, where Foo and Bar are group @@ -489,12 +485,12 @@ function getItemPathString(item) { } } -function parsePathString(pathString) { +export function parsePathString(pathString) { const pathParts = pathString.split('/').filter(item => item.length) return pathParts } -function getTrackIndexInParent(track) { +export function getTrackIndexInParent(track) { if (parentSymbol in track === false) { throw new Error( 'getTrackIndexInParent called with a track that has no parent!' @@ -505,6 +501,11 @@ function getTrackIndexInParent(track) { let i = 0, foundTrack = false; for (; i < parent.items.length; i++) { + // TODO: Port isSameTrack from http-music, if it makes sense - doing + // so involves porting the [oldSymbol] property on all tracks and groups, + // so may or may not be the right call. This function isn't used anywhere + // in mtui so it'll take a little extra investigation. + /* eslint-disable-next-line no-undef */ if (isSameTrack(track, parent.items[i])) { foundTrack = true break @@ -519,14 +520,14 @@ function getTrackIndexInParent(track) { } const nameWithoutTrackNumberSymbol = Symbol('Cached name without track number') -function getNameWithoutTrackNumber(track) { +export function getNameWithoutTrackNumber(track) { // A "part" is a series of numeric digits, separated from other parts by // whitespace, dashes, and dots, always preceding either the first non- // numeric/separator character or (if there are no such characters) the // first word (i.e. last whitespace). const getNumberOfParts = ({ name }) => { - name = name.replace(/^[\-\s.]+$/, '') - const match = name.match(/[^0-9\-\s.]/) + name = name.replace(/^[-\s.]+$/, '') + const match = name.match(/[^0-9-\s.]/) if (match) { if (match.index === 0) { return 0 @@ -538,12 +539,12 @@ function getNameWithoutTrackNumber(track) { } else { return 0 } - name = name.replace(/[\-\s.]+$/, '') - return name.split(/[\-\s.]+/g).length + name = name.replace(/[-\s.]+$/, '') + return name.split(/[-\s.]+/g).length } const removeParts = (name, numParts) => { - const regex = new RegExp(String.raw`[\-\s.]{0,}([0-9]+[\-\s.]+){${numParts},${numParts}}`) + const regex = new RegExp(String.raw`[-\s.]{0,}([0-9]+[-\s.]+){${numParts},${numParts}}`) return track.name.replace(regex, '') } @@ -591,24 +592,24 @@ function getNameWithoutTrackNumber(track) { } } -function isGroup(obj) { +export function isGroup(obj) { return !!(obj && obj.items) } -function isTrack(obj) { +export function isTrack(obj) { return !!(obj && obj.downloaderArg) } -function isPlayable(obj) { +export function isPlayable(obj) { return isGroup(obj) || isTrack(obj) } -function isOpenable(obj) { +export function isOpenable(obj) { return !!(obj && obj.url) } -function searchForItem(grouplike, value, preferredStartIndex = -1) { +export function searchForItem(grouplike, value, preferredStartIndex = -1) { if (value.length) { // We prioritize searching past the index that the user opened the jump // element from (oldFocusedIndex). This is so that it's more practical @@ -648,7 +649,7 @@ function searchForItem(grouplike, value, preferredStartIndex = -1) { return null } -function getCorrespondingFileForItem(item, extension) { +export function getCorrespondingFileForItem(item, extension) { if (!(item && item.url)) { return null } @@ -673,7 +674,7 @@ function getCorrespondingFileForItem(item, extension) { return null } -function getCorrespondingPlayableForFile(item) { +export function getCorrespondingPlayableForFile(item) { if (!(item && item.url)) { return null } @@ -691,53 +692,3 @@ function getCorrespondingPlayableForFile(item) { const basename = path.basename(item.url, path.extname(item.url)) return parent.items.find(item => isPlayable(item) && path.basename(item.url, path.extname(item.url)) === basename) } - -module.exports = { - parentSymbol, - updatePlaylistFormat, updateGroupFormat, updateTrackFormat, - cloneGrouplike, - filterTracks, - flattenGrouplike, countTotalTracks, - shuffleOrderOfGroups, - reverseOrderOfGroups, - partiallyFlattenGrouplike, collapseGrouplike, - filterGrouplikeByProperty, - filterPlaylistByPathString, filterGrouplikeByPath, - removeGroupByPathString, removeGroupByPath, - getPlaylistTreeString, - getItemPath, getItemPathString, - parsePathString, - getTrackIndexInParent, - getNameWithoutTrackNumber, - searchForItem, - getCorrespondingFileForItem, - getCorrespondingPlayableForFile, - isGroup, isTrack, - isOpenable, isPlayable -} - -if (require.main === module) { - { - const group = updateGroupFormat({items: [ - {name: '- 1.01 Hello World 425', downloaderArg: 'x'}, - {name: '1.02 Aww Yeah 371', downloaderArg: 'x'}, - {name: ' 1.03 Here Goes 472', downloaderArg: 'x'} - ]}) - - for (let i = 0; i < group.items.length; i++) { - console.log(group.items[i].name, '->', getNameWithoutTrackNumber(group.items[i])) - } - } - - { - const group = updateGroupFormat({items: [ - {name: 'BAM #1', downloaderArg: 'x'}, - {name: 'BAM #2', downloaderArg: 'x'}, - {name: 'BAM #3.1 - no', downloaderArg: 'x'} - ]}) - - for (let i = 0; i < group.items.length; i++) { - console.log(group.items[i].name, '->', getNameWithoutTrackNumber(group.items[i])) - } - } -} diff --git a/record-store.js b/record-store.js index 80c8d3a..2686457 100644 --- a/record-store.js +++ b/record-store.js @@ -1,6 +1,6 @@ const recordSymbolKey = Symbol('Record symbol') -module.exports = class RecordStore { +export default class RecordStore { constructor() { // Each track (or whatever) gets a symbol which is used as a key here to // store more information. diff --git a/smart-playlist.js b/smart-playlist.js index 19294db..c8abf62 100644 --- a/smart-playlist.js +++ b/smart-playlist.js @@ -1,7 +1,7 @@ -const { getCrawlerByName } = require('./crawlers') -const { isGroup, filterTracks, sourceSymbol, updatePlaylistFormat } = require('./playlist-utils') +import {getCrawlerByName} from './crawlers.js' +import {filterTracks, isGroup, updatePlaylistFormat} from './playlist-utils.js' -async function processSmartPlaylist(item, topItem = true) { +export default async function processSmartPlaylist(item, topItem = true) { // Object.assign is used so that we keep original properties, e.g. "name" // or "apply". (It's also used so we return copies of original objects.) @@ -133,5 +133,3 @@ async function processSmartPlaylist(item, topItem = true) { return newItem } } - -module.exports = processSmartPlaylist diff --git a/socat.js b/socat.js index 8871c7e..a465a73 100644 --- a/socat.js +++ b/socat.js @@ -2,16 +2,14 @@ // Assumes access to the `socat` command as a child process; if it's not // present, it will fall back to just writing to the specified file. -const EventEmitter = require('events') -const { spawn } = require('child_process') -const { killProcess, commandExists } = require('./general-util') -const { promisify } = require('util') -const fs = require('fs') -const path = require('path') +import {spawn} from 'node:child_process' +import {writeFile} from 'node:fs/promises' +import EventEmitter from 'node:events' +import path from 'node:path' -const writeFile = promisify(fs.writeFile) +import {killProcess, commandExists} from './general-util.js' -module.exports = class Socat extends EventEmitter { +export default class Socat extends EventEmitter { constructor(path) { super() this.setPath(path) @@ -30,7 +28,7 @@ module.exports = class Socat extends EventEmitter { this.subprocess.on('close', () => { this.subprocess = null }) - this.subprocess.stdin.on('error', err => { + this.subprocess.stdin.on('error', () => { this.stop() }) } @@ -69,7 +67,7 @@ module.exports = class Socat extends EventEmitter { } } else { try { - await writeFile(path.resolve(__dirname, this.path), message + '\r\n') + await writeFile(path.resolve(process.cwd(), this.path), message + '\r\n') } catch (error) { // :shrug: We tried! // -- It's possible to get here if the specified path isn't an actual diff --git a/telnet.js b/telnet.js index 33e3dcc..42e664d 100644 --- a/telnet.js +++ b/telnet.js @@ -1,16 +1,11 @@ -'use strict' +import EventEmitter from 'node:events' +import net from 'node:net' -const EventEmitter = require('events') -const net = require('net') -const setupClient = require('./client') +import {TelnetInterface} from 'tui-lib/util/interfaces' -const { - util: { - TelnetInterfacer - } -} = require('tui-lib') +import setupClient from './client.js' -class TelnetServer extends EventEmitter { +export default class TelnetServer extends EventEmitter { constructor(backend) { super() @@ -24,11 +19,11 @@ class TelnetServer extends EventEmitter { } async handleConnection(socket) { - const interfacer = new TelnetInterfacer(socket) + const telnetInterface = new TelnetInterface(socket) const { appElement, cleanTerminal, flushable } = await setupClient({ backend: this.backend, writable: socket, - interfacer, + screenInterface: telnetInterface, appConfig: { canControlPlayback: false, canControlQueue: true, @@ -47,7 +42,7 @@ class TelnetServer extends EventEmitter { const quit = (msg = 'See you!') => { cleanTerminal() - interfacer.cleanTelnetOptions() + telnetInterface.cleanTelnetOptions() socket.write('\r' + msg + '\r\n') socket.end() flushable.end() @@ -77,5 +72,3 @@ class TelnetServer extends EventEmitter { } } } - -module.exports = TelnetServer diff --git a/todo.txt b/todo.txt index b10c614..4c93789 100644 --- a/todo.txt +++ b/todo.txt @@ -690,3 +690,9 @@ TODO: Apparently pressing any key while the UI is booting up will make the screen totally black and unresponsive (and apparently inactive) until the screen is resized. I think we're interrupting a control sequence somehow, and that isn't being handled very well? + +TODO: Pressing escape while you've got items selected should deselect those + items, rather than stop playback! ...Or SHOULD IT??? Well, yes. But it's + still handy to not be locked out of stopping playback altogether. + Alternative: clear the selection (without stopping playback) only if the + cursor is currently on a selected item. diff --git a/ui.js b/ui.js index dc9dab9..f006a70 100644 --- a/ui.js +++ b/ui.js @@ -1,20 +1,35 @@ // 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 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, @@ -30,46 +45,14 @@ const { parentSymbol, reverseOrderOfGroups, searchForItem, - shuffleOrderOfGroups -} = require('./playlist-utils') - -const { - ui: { - Dialog, - DisplayElement, - Label, - Pane, - WrapLabel, - form: { - Button, - FocusElement, - Form, - ListScrollForm, - TextInput, - } - }, - util: { - ansi, - telchars: telc, - unichars: unic, - } -} = require('tui-lib') + shuffleOrderOfGroups, +} from './playlist-utils.js' /* text editor features disabled because theyre very much incomplete and havent * gotten much use from me or anyonea 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 = [ @@ -186,7 +169,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() @@ -269,7 +252,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)) @@ -441,7 +424,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 }) @@ -1374,9 +1357,9 @@ class AppElement extends FocusElement { } 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 => () => { @@ -1387,7 +1370,7 @@ class AppElement extends FocusElement { }) } - 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)} @@ -1431,13 +1414,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}, canControlQueue && isPlayable(item) && {element: this.whereControl}, canControlQueue && isGroup(item) && {element: this.orderControl}, @@ -1450,10 +1432,8 @@ class AppElement extends FocusElement { 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}, @@ -1475,7 +1455,7 @@ class AppElement extends FocusElement { ].filter(Boolean) // TODO: Implement this! :P - const isMarked = false + // const isMarked = false this.showContextMenu({ x: el.absLeft, @@ -2250,7 +2230,7 @@ class AppElement extends FocusElement { } 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 { @@ -2745,6 +2725,7 @@ class GrouplikeListingElement extends Form { // 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() @@ -2916,7 +2897,6 @@ class GrouplikeListingForm extends ListScrollForm { set curIndex(newIndex) { this.setDep('curIndex', newIndex) this.emit('select', this.inputs[this.curIndex]) - return newIndex } get curIndex() { @@ -3007,7 +2987,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) @@ -3331,8 +3310,8 @@ class InlineListPickerElement extends FocusElement { this.showContextMenu({ x: this.absLeft + ansi.measureColumns(this.labelText) + 1, y: this.absTop + 1, - items: this.options.map(({ value, label }, index) => ({ - label: label, + items: this.options.map(({ label }, index) => ({ + label, action: () => { this.curIndex = index }, @@ -3378,7 +3357,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 @@ -3860,7 +3839,7 @@ 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)) { @@ -3905,7 +3884,6 @@ class TimestampGrouplikeItemElement extends BasicGrouplikeItemElement { || last.timestampEnd !== Infinity && last.timestampEnd || last.timestamp) const strings = getTimeStringsFromSec(data.timestamp, duration) - const stringsEnd = getTimeStringsFromSec(data.timestampEnd, duration) this.text = ( /* @@ -4319,7 +4297,6 @@ class PlaybackInfoElement extends FocusElement { this.app.backend.queuePlayers.length > 1 && { label: 'Delete', action: () => { - const { parent } = this this.app.removeQueuePlayer(this.queuePlayer) } } @@ -4414,17 +4391,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 { @@ -5259,9 +5236,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 { @@ -5396,5 +5373,3 @@ class NotesTextEditor extends TuiTextEditor { } } */ - -module.exports = AppElement diff --git a/undo-manager.js b/undo-manager.js index 4a042ad..9b53c2d 100644 --- a/undo-manager.js +++ b/undo-manager.js @@ -1,4 +1,4 @@ -class UndoManager { +export default class UndoManager { constructor() { this.actionStack = [] this.undoneStack = [] @@ -38,5 +38,3 @@ class UndoManager { return this.undoStack.length === 0 } } - -module.exports = UndoManager -- cgit 1.3.0-6-gf8a5 From c3425f516dfabe15c71b37faa9fa27ec55612900 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 13 May 2023 18:09:51 -0300 Subject: skip .DS_Store, .git in crawl-local --- crawlers.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crawlers.js b/crawlers.js index b2f13fd..8197095 100644 --- a/crawlers.js +++ b/crawlers.js @@ -16,6 +16,11 @@ export const musicExtensions = [ 'mod' ] +export const skipNames = [ + '.DS_Store', + '.git', +] + // Each value is a function with these additional properties: // * crawlerName: The name of the crawler, such as "crawl-http". Used by // getCrawlerByName. @@ -251,6 +256,12 @@ function crawlLocal(dirPath, extensions = musicExtensions, isTop = true) { items = orderBy(items) return Promise.all(items.map(item => { + // There are a few files which are just never what we're looking for. + // We skip including or searching under these altogether. + if (skipNames.includes(item)) { + return null + } + const itemPath = path.join(dirPath, item) const itemURL = url.pathToFileURL(itemPath).href -- cgit 1.3.0-6-gf8a5 From a36e372ba88b59e08fa938f76b261fdc2797bef2 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 13 May 2023 18:11:34 -0300 Subject: load basic config file from ~/.mtui/config.json This is basically just a stub for now, but you can specify what playlists you want open when mtui is called without any provided directories/playlists in "defaultPlaylists'! Also comes with complementary --config-file and --skip-config-file arguments. --- index.js | 51 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/index.js b/index.js index a5930bc..7632844 100755 --- a/index.js +++ b/index.js @@ -12,8 +12,9 @@ import TelnetServer from './telnet.js' import {CommandLineInterface} from 'tui-lib/util/interfaces' import * as ansi from 'tui-lib/util/ansi' -import {writeFile} from 'node:fs/promises' +import {readFile, writeFile} from 'node:fs/promises' import os from 'node:os' +import path from 'node:path' // Hack to get around errors when piping many things to stdout/err // (from general-util promisifyProcess) @@ -48,12 +49,16 @@ async function main() { } } }, + 'player-options': {type: 'series'}, 'stress-test': {type: 'flag'}, 'telnet-server': {type: 'flag'}, + 'skip-config-file': {type: 'flag'}, + 'config-file': {type: 'value'}, + [parseOptions.handleDashless](option) { playlistSources.push(option) - } + }, }) if (options['player-options'] && !options['player']) { @@ -61,6 +66,30 @@ async function main() { process.exit(1) } + let jsonConfig = {} + let jsonError = null + + const jsonPath = + (options['config-file'] + ? path.resolve(options['config-file']) + : path.join(os.homedir(), '.mtui', 'config.json')) + + try { + jsonConfig = JSON.parse(await readFile(jsonPath)) + } catch (error) { + if (error.code !== 'ENOENT') { + jsonError = error + } + } + + if (jsonError) { + console.error(`Error loading JSON config:`) + console.error(jsonError.message) + console.error(`Edit the file below to fix the error, or run mtui with --skip-config-file.`) + console.error(jsonPath) + process.exit(1) + } + const backend = new Backend({ playerName: options['player'], playerOptions: options['player-options'] @@ -107,13 +136,17 @@ async function main() { }) if (playlistSources.length === 0) { - playlistSources.push({ - name: 'My ~/Music Library', - comment: ( - '(Add tracks and folders to ~/Music to make them show up here,' + - ' or pass mtui your own playlist.json file!)'), - source: ['crawl-local', os.homedir() + '/Music'] - }) + if (jsonConfig.defaultPlaylists) { + playlistSources.push(...jsonConfig.defaultPlaylists) + } else { + playlistSources.push({ + name: 'My ~/Music Library', + comment: ( + '(Add tracks and folders to ~/Music to make them show up here,' + + ' or pass mtui your own playlist.json file!)'), + source: ['crawl-local', os.homedir() + '/Music'] + }) + } } const loadPlaylists = async () => { -- cgit 1.3.0-6-gf8a5