diff options
-rw-r--r-- | .eslintrc | 24 | ||||
-rw-r--r-- | README.md | 2 | ||||
-rw-r--r-- | backend.js | 220 | ||||
-rw-r--r-- | client.js | 46 | ||||
-rw-r--r-- | combine-album.js | 223 | ||||
-rw-r--r-- | crawlers.js | 65 | ||||
-rw-r--r-- | downloaders.js | 94 | ||||
-rw-r--r-- | general-util.js | 75 | ||||
-rw-r--r-- | guess.js | 24 | ||||
-rwxr-xr-x | index.js | 97 | ||||
-rw-r--r-- | metadata-readers.js | 29 | ||||
-rw-r--r-- | package-lock.json | 1362 | ||||
-rw-r--r-- | package.json | 11 | ||||
-rw-r--r-- | players.js | 157 | ||||
-rw-r--r-- | playlist-utils.js | 192 | ||||
-rw-r--r-- | record-store.js | 2 | ||||
-rw-r--r-- | serialized-backend.js | 35 | ||||
-rw-r--r-- | smart-playlist.js | 8 | ||||
-rw-r--r-- | socat.js | 18 | ||||
-rw-r--r-- | socket.js | 274 | ||||
-rw-r--r-- | telnet.js | 23 | ||||
-rw-r--r-- | todo.txt | 128 | ||||
-rw-r--r-- | ui.js | 1220 | ||||
-rw-r--r-- | undo-manager.js | 4 |
24 files changed, 3279 insertions, 1054 deletions
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/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 0f15a43..6576868 100644 --- a/backend.js +++ b/backend.js @@ -3,32 +3,29 @@ 'use strict' -const { getDownloaderFor } = require('./downloaders') -const { getMetadataReaderFor } = require('./metadata-readers') -const { getPlayer } = require('./players') -const RecordStore = require('./record-store') -const os = require('os') -const shortid = require('shortid') - -const { +import {readFile, writeFile} from 'node:fs/promises' +import EventEmitter from 'node:events' +import os from 'node:os' + +import shortid from 'shortid' + +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)) { @@ -69,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 this.time = null @@ -92,9 +89,10 @@ class QueuePlayer extends EventEmitter { this.player.on('printStatusLine', data => { if (this.playingTrack) { + const oldTimeData = this.timeData this.timeData = data this.time = data.curSecTotal - this.emit('received time data', data, this) + this.emit('received time data', data, oldTimeData) } }) @@ -217,20 +215,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) { @@ -249,7 +233,7 @@ class QueuePlayer extends EventEmitter { } } - this.emit('distribute-queue', topItem, {how, rangeEnd}) + this.emit('distribute queue', topItem, {how, rangeEnd}) this.emitQueueUpdated() } @@ -317,7 +301,7 @@ class QueuePlayer extends EventEmitter { items.splice(index) } - this.emit('clear-queue-past', track) + this.emit('clear queue past', track) this.emitQueueUpdated() } @@ -334,7 +318,7 @@ class QueuePlayer extends EventEmitter { items.splice(startIndex, endIndex - startIndex) } - this.emit('clear-queue-up-to', track) + this.emit('clear queue up to', track) this.emitQueueUpdated() } @@ -358,14 +342,16 @@ 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)) queue.items = newItems - this.emit('shuffle-queue') + this.emit('shuffle queue') this.emitQueueUpdated() } @@ -374,7 +360,7 @@ class QueuePlayer extends EventEmitter { // the track that's currently playing). this.queueGrouplike.items = this.queueGrouplike.items .filter(item => item === this.playingTrack) - this.emit('clear-queue') + this.emit('clear queue') this.emitQueueUpdated() } @@ -390,8 +376,7 @@ class QueuePlayer extends EventEmitter { this.clearPlayingTrack() } - - async play(item, forceStartPaused) { + async play(item, startTime = 0, forceStartPaused = false) { if (this.player === null) { throw new Error('Attempted to play before a player was loaded') } @@ -439,8 +424,8 @@ class QueuePlayer extends EventEmitter { this.timeData = null this.time = null this.playingTrack = item - this.emit('playing details', this.playingTrack, oldTrack, this) - this.emit('playing', this.playingTrack, oldTrack, this) + this.emit('playing details', this.playingTrack, oldTrack, startTime) + this.emit('playing', this.playingTrack) await this.player.kill() if (this.alwaysStartPaused || forceStartPaused) { @@ -452,7 +437,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 @@ -462,13 +447,7 @@ class QueuePlayer extends EventEmitter { this.playedTrackToEnd = true this.emit('done playing', this.playingTrack) if (!this.waitWhenDonePlaying) { - if (!this.playNext(item)) { - if (this.loopQueueAtEnd) { - this.playFirst() - } else { - this.clearPlayingTrack() - } - } + this.playNext(item) } } } @@ -476,6 +455,12 @@ class QueuePlayer extends EventEmitter { 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 @@ -494,7 +479,7 @@ class QueuePlayer extends EventEmitter { this.queue(nextItem) queueIndex = queue.items.length - 1 } else { - return false + return this.playNextAtQueueEnd() } } @@ -540,14 +525,51 @@ class QueuePlayer extends EventEmitter { return false } + playNextAtQueueEnd() { + switch (this.queueEndMode) { + case 'loop': + this.playFirst() + return true + case 'shuffle': + this.shuffleQueue(false) + 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! + return + } + + if (item === this.playingTrack) { + this.seekTo(time) + } else { + // 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) + } + } + clearPlayingTrack() { if (this.playingTrack !== null) { const oldTrack = this.playingTrack this.playingTrack = null this.timeData = null this.time = null - this.emit('playing details', null, oldTrack, this) - this.emit('playing', null, oldTrack, this) + this.emit('playing details', null, oldTrack, 0) + this.emit('playing', null) } } @@ -558,7 +580,7 @@ class QueuePlayer extends EventEmitter { seekAhead(seconds) { this.time += seconds this.player.seekAhead(seconds) - this.emit('seek-ahead', +seconds) + this.emit('seek ahead', +seconds) } seekBack(seconds) { @@ -568,48 +590,56 @@ class QueuePlayer extends EventEmitter { this.time -= seconds } this.player.seekBack(seconds) - this.emit('seek-back', +seconds) + this.emit('seek back', +seconds) } seekTo(timeInSecs) { this.time = timeInSecs this.player.seekTo(timeInSecs) - this.emit('seek-to', +timeInSecs) + this.emit('seek to', +timeInSecs) + } + + seekTo(seconds) { + this.player.seekTo(seconds) + } + + seekToStart() { + this.player.seekToStart() } togglePause() { this.player.togglePause() - this.emit('toggle-pause') + this.emit('toggle pause') } setPause(value) { this.player.setPause(value) - this.emit('set-pause', !!value) + this.emit('set pause', !!value) } toggleLoop() { this.player.toggleLoop() - this.emit('toggle-loop') + this.emit('toggle loop') } setLoop(value) { this.player.setLoop(value) - this.emit('set-loop', !!value) + this.emit('set loop', !!value) } - volUp(amount = 10) { + volumeUp(amount = 10) { this.player.volUp(amount) - this.emit('vol-up', +amount) + this.emit('volume up', +amount) } - volDown(amount = 10) { + volumeDown(amount = 10) { this.player.volDown(amount) - this.emit('vol-down', +amount) + this.emit('volume down', +amount) } setVolume(value) { this.player.setVolume(value) - this.emit('set-volume', +value) + this.emit('set volume', +value) } setVolumeMultiplier(value) { @@ -622,12 +652,12 @@ class QueuePlayer extends EventEmitter { setPauseNextTrack(value) { this.pauseNextTrack = !!value - this.emit('set-pause-next-track', !!value) + this.emit('set pause next track', !!value) } setLoopQueueAtEnd(value) { this.loopQueueAtEnd = !!value - this.emit('set-loop-queue-at-end', !!value) + this.emit('set loop queue at end', !!value) } get remainingTracks() { @@ -653,7 +683,7 @@ class QueuePlayer extends EventEmitter { } } -class Backend extends EventEmitter { +export default class Backend extends EventEmitter { constructor({ playerName = null, playerOptions = [] @@ -675,6 +705,7 @@ class Backend extends EventEmitter { this.sharedSourcesMap = Object.create(null) this.sharedSourcesGrouplike = { name: 'Shared Sources', + isPartySources: true, items: [] } @@ -715,30 +746,33 @@ class Backend extends EventEmitter { this.emit('added queue player', queuePlayer) for (const event of [ - 'playing', + 'clear queue', + 'clear queue past', + 'clear queue up to', + 'distribute queue', 'done playing', + 'playing', + 'playing details', 'queue', - 'distribute-queue', - 'unqueue', - 'clear-queue-past', - 'clear-queue-up-to', - 'shuffle-queue', - 'clear-queue', 'queue updated', - 'seek-ahead', - 'seek-back', - 'toggle-pause', - 'set-pause', - 'toggle-loop', - 'set-loop', - 'vol-up', - 'vol-down', - 'set-volume', - 'set-pause-next-track', - 'set-loop-queue-at-end' + 'received time data', + 'seek ahead', + 'seek back', + 'seek to', + 'set loop', + 'set loop queue at end', + 'set pause', + 'set pause next track', + 'set volume', + 'shuffle queue', + 'toggle loop', + 'toggle pause', + 'unqueue', + 'volume down', + 'volume up', ]) { queuePlayer.on(event, (...data) => { - this.emit(event, queuePlayer, ...data) + this.emit('QP: ' + event, queuePlayer, ...data) }) } @@ -899,5 +933,3 @@ class Backend extends EventEmitter { this.emit('share with party', item) } } - -module.exports = Backend diff --git a/client.js b/client.js index aa854ed..0af45f6 100644 --- a/client.js +++ b/client.js @@ -1,23 +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 os = require('os') - -const { - ui: { - Root - }, - util: { - ansi, - Flushable, - TelnetInterfacer - } -} = require('tui-lib') +import AppElement from './ui.js' + +import {Root} from 'tui-lib/ui/primitives' + +import {Flushable} from 'tui-lib/util/interfaces' +import * as ansi from 'tui-lib/util/ansi' -const setupClient = async ({backend, writable, interfacer, appConfig}) => { +export default async function setupClient({ + backend, + writable, + screenInterface, + appConfig, +}) { const cleanTerminal = () => { writable.write(ansi.cleanCursor()) writable.write(ansi.disableAlternateScreen()) @@ -31,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() @@ -43,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) @@ -63,15 +58,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 @@ -79,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 new file mode 100644 index 0000000..3b57b6c --- /dev/null +++ b/combine-album.js @@ -0,0 +1,223 @@ +'use strict' + +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 = [] + + 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 + } + + 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 => `${getTimeStringsFromSec(t.timestamp, duration, true).timeDone} ${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 ${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])}`) + } 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..8197095 100644 --- a/crawlers.js +++ b/crawlers.js @@ -1,15 +1,25 @@ -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) +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' +] + +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 @@ -21,7 +31,7 @@ const stat = promisify(fs.stat) 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. @@ -229,12 +239,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) @@ -247,10 +252,16 @@ function crawlLocal(dirPath, extensions = [ dirPath = expandHomeDir(dirPath) } - return readDir(dirPath).then(items => { + return readdir(dirPath).then(items => { 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 @@ -274,7 +285,7 @@ function crawlLocal(dirPath, extensions = [ return {name: item, url: itemURL} } } - }, statErr => null) + }, _statErr => null) })) }, err => { if (err.code === 'ENOENT') { @@ -321,7 +332,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', @@ -381,7 +392,7 @@ crawlYouTube.isAppropriateForArg = function(arg) { allCrawlers.crawlYouTube = crawlYouTube -async function openFile(input) { +export async function openFile(input) { return JSON.parse(await downloadPlaylistFromOptionValue(input)) } @@ -394,14 +405,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 85ff8e5..536b3fd 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,17 @@ module.exports.throttlePromise = function(maximumAtOneTime = 10) { return enqueue } -module.exports.getTimeStringsFromSec = function(curSecTotal, lenSecTotal) { +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] + case 2: return parts[0] * 60 + parts[1] + case 1: return parts[0] + default: return 0 + } +} + +export function getTimeStringsFromSec(curSecTotal, lenSecTotal, fraction = false) { const percentVal = (100 / lenSecTotal) * curSecTotal const percentDone = ( (Math.trunc(percentVal * 100) / 100).toFixed(2) + '%' @@ -149,29 +157,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}` @@ -181,19 +196,25 @@ module.exports.getTimeStringsFromSec = function(curSecTotal, lenSecTotal) { duration = `${lenMin}:${lenSec}` } + if (fraction) { + timeDone += '.' + curFrac + timeLeft += '.' + leftFrac + duration += '.' + lenFrac + } + 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: // @@ -309,9 +330,7 @@ const parseOptions = async function(options, optionDescriptorMap) { parseOptions.handleDashless = Symbol() -module.exports.parseOptions = parseOptions - -module.exports.silenceEvents = async function(emitter, eventsToSilence, callback) { +export async function silenceEvents(emitter, eventsToSilence, callback) { const oldEmit = emitter.emit emitter.emit = function(event, ...data) { @@ -327,7 +346,7 @@ module.exports.silenceEvents = async function(emitter, eventsToSilence, callback // Kindly stolen from ESDiscuss: // https://esdiscuss.org/topic/proposal-add-an-option-to-omit-prototype-of-objects-created-by-json-parse#content-1 -module.exports.parseWithoutPrototype = function(string) { +export function parseWithoutPrototype(string) { return JSON.parse(string, function(k, v) { if (v && typeof v === 'object' && !Array.isArray(v)) { return Object.assign(Object.create(null), v) 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 3ecd59b..fea9222 100755 --- a/index.js +++ b/index.js @@ -2,42 +2,26 @@ // 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 { +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 { makeSocketServer, makeSocketClient, attachBackendToSocketClient, - attachSocketServerToBackend -} = require('./socket') - -const { - getItemPathString, - updatePlaylistFormat -} = require('./playlist-utils') - -const { - ui: { - Root - }, - util: { - ansi, - CommandLineInterfacer, - Flushable - } -} = require('tui-lib') + attachSocketServerToBackend, +} from './socket.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 readFile = promisify(fs.readFile) -const writeFile = promisify(fs.writeFile) +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) @@ -72,15 +56,19 @@ async function main() { } } }, + 'player-options': {type: 'series'}, 'stress-test': {type: 'flag'}, 'socket-client': {type: 'value'}, 'socket-name': {type: 'value'}, 'socket-server': {type: 'value'}, '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']) { @@ -88,6 +76,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'] @@ -110,11 +122,11 @@ async function main() { const { appElement, dirtyTerminal, flushable, root } = await setupClient({ backend, - interfacer: new CommandLineInterfacer(), + screenInterface: new CommandLineInterface(), writable: process.stdout, appConfig: { showPartyControls: !!(options['socket-server'] || options['socket-client']) - } + }, }) appElement.on('quitRequested', () => { @@ -136,6 +148,20 @@ async function main() { root.renderNow() }) + if (playlistSources.length === 0) { + 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 () => { for (const source of playlistSources) { await appElement.loadPlaylistOrSource(source, true) @@ -191,6 +217,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 64f413a..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', @@ -37,7 +40,11 @@ const metadataReaders = { probeDataString += data }) - await promisifyProcess(ffprobe, false) + try { + await promisifyProcess(ffprobe, false) + } catch (error) { + return null + } let data @@ -56,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 3d18627..25df086 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,74 +1,756 @@ { "name": "mtui", "version": "0.0.1", - "lockfileVersion": 2, + "lockfileVersion": 3, "requires": true, "packages": { "": { + "name": "mtui", "version": "0.0.1", "license": "GPL-3.0", "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.4", "sanitize-filename": "^1.6.3", + "shell-escape": "^0.2.0", "shortid": "^2.2.15", "tempy": "^0.2.1", - "tui-lib": "^0.3.1" + "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": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=", + "license": "MIT", "engines": { "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==" + "license": "MIT" + }, + "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", - "integrity": "sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=", + "license": "MIT", "engines": { "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", - "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", + "license": "MIT", "dependencies": { "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=" + "license": "BSD" + }, + "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.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.0.0.tgz", - "integrity": "sha512-pJEdRugimx4fBMra5z2/5iRdZ63OhYV0vr0Dwm5+xtW4D1FvRkB8hamMIhnWfyJeDdyr/aa7BDyNbtG38VxgoQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", "dependencies": { "is-docker": "^2.0.0" }, @@ -76,277 +758,575 @@ "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/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.5", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", - "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "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": { - "minimist": "^1.2.5" + "argparse": "^2.0.1" }, "bin": { - "mkdirp": "bin/cmd.js" + "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": { + "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": "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/nanoid": { "version": "2.1.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-2.1.11.tgz", - "integrity": "sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA==" + "license": "MIT" + }, + "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", - "integrity": "sha512-p7KTHxU0CUrcOXe62Zfrb5Z13nLvPhSWR/so3kFulUQU0sgUll2Z0LwpsLN351eOOD+hRGu/F1g+6xDfPeD++Q==", + "license": "MIT", "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==", + "license": "MIT", "engines": { "node": "4.x || >=6.0.0" } }, + "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.0.4", - "resolved": "https://registry.npmjs.org/open/-/open-7.0.4.tgz", - "integrity": "sha512-brSA+/yq+b08Hsr4c8fsEW2CRzk1BmfN3SAK/5VCHQ9bdoZJ4qa/+AfR0xHjlbbZUyPkUHs1b8x1RqdyZdkVqQ==", + "license": "MIT", "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==", + "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": { - "truncate-utf8-bytes": "^1.0.0" + "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/shortid": { - "version": "2.2.15", - "resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.15.tgz", - "integrity": "sha512-5EaCy2mx2Jgc/Fdn9uuDuNIIfWBpzY4XIlhoqtXF6qsf+/+SGZ+FxDdX/ZsMZiWupIWNqAEmiNY4RC+LSmCeOw==", + "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": { - "nanoid": "^2.1.0" - } - }, - "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=", + "yocto-queue": "^0.1.0" + }, "engines": { - "node": ">=4" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tempy": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.2.1.tgz", - "integrity": "sha512-LB83o9bfZGrntdqPuRdanIVCPReam9SOZKW0fOy5I9X3A854GGWi0tjCqoXEk84XIEYBc/x9Hq3EFop/H5wJaw==", + "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": { - "temp-dir": "^1.0.0", - "unique-string": "^1.0.0" + "p-limit": "^3.0.2" }, "engines": { - "node": ">=4" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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=", + "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": { - "utf8-byte-length": "^1.0.1" + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" } }, - "node_modules/tui-lib": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.3.1.tgz", - "integrity": "sha512-uCE2j351/b4C2Q3eEhC54EvZiWbgJ/Q3gH5ElS2D+mvRmWbHDzXbPUhcXrx8oOA5rZFZ4iNVMCoLCqzWWZTJyQ==", - "dependencies": { - "wcwidth": "^1.0.1" + "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/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" - }, + "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": ">=4" + "node": ">=0.10.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", - "integrity": "sha1-9F8VDExm7uloGGUFq5P8u4rWv2E=" + "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/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/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" } - } - }, - "dependencies": { - "clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=" }, - "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/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" + } }, - "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=" + "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" + } + ] }, - "defaults": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", - "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", - "requires": { - "clone": "^1.0.2" + "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" } }, - "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/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" + } }, - "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==" + "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" + } }, - "is-wsl": { - "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" + "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" } }, - "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + "node_modules/sanitize-filename": { + "version": "1.6.3", + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } }, - "mkdirp": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", - "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", - "requires": { - "minimist": "^1.2.5" + "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" } }, - "nanoid": { - "version": "2.1.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-2.1.11.tgz", - "integrity": "sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA==" + "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" + } }, - "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==" + "node_modules/shell-escape": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz", + "integrity": "sha512-uRRBT2MfEOyxuECseCZd28jC1AJ8hmqqneWQ4VWUTgCAFvb3wKU1jLqj6egC4Exrr88ogg3dp+zroH4wJuaXzw==" }, - "node-fetch": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", - "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==" + "node_modules/shortid": { + "version": "2.2.15", + "license": "MIT", + "dependencies": { + "nanoid": "^2.1.0" + } }, - "open": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/open/-/open-7.0.4.tgz", - "integrity": "sha512-brSA+/yq+b08Hsr4c8fsEW2CRzk1BmfN3SAK/5VCHQ9bdoZJ4qa/+AfR0xHjlbbZUyPkUHs1b8x1RqdyZdkVqQ==", - "requires": { - "is-docker": "^2.0.0", - "is-wsl": "^2.1.1" + "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" } }, - "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==", - "requires": { - "truncate-utf8-bytes": "^1.0.0" + "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" } }, - "shortid": { - "version": "2.2.15", - "resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.15.tgz", - "integrity": "sha512-5EaCy2mx2Jgc/Fdn9uuDuNIIfWBpzY4XIlhoqtXF6qsf+/+SGZ+FxDdX/ZsMZiWupIWNqAEmiNY4RC+LSmCeOw==", - "requires": { - "nanoid": "^2.1.0" + "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": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" } }, - "temp-dir": { + "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=" + "license": "MIT", + "engines": { + "node": ">=4" + } }, - "tempy": { + "node_modules/tempy": { "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.2.1.tgz", - "integrity": "sha512-LB83o9bfZGrntdqPuRdanIVCPReam9SOZKW0fOy5I9X3A854GGWi0tjCqoXEk84XIEYBc/x9Hq3EFop/H5wJaw==", - "requires": { + "license": "MIT", + "dependencies": { "temp-dir": "^1.0.0", "unique-string": "^1.0.0" + }, + "engines": { + "node": ">=4" } }, - "truncate-utf8-bytes": { + "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/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=", - "requires": { + "license": "WTFPL", + "dependencies": { "utf8-byte-length": "^1.0.1" } }, - "tui-lib": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.3.1.tgz", - "integrity": "sha512-uCE2j351/b4C2Q3eEhC54EvZiWbgJ/Q3gH5ElS2D+mvRmWbHDzXbPUhcXrx8oOA5rZFZ4iNVMCoLCqzWWZTJyQ==", - "requires": { + "node_modules/tui-lib": { + "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" } }, - "unique-string": { + "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", + "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/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", - "integrity": "sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=", - "requires": { + "license": "MIT", + "dependencies": { "crypto-random-string": "^1.0.0" + }, + "engines": { + "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" } }, - "utf8-byte-length": { + "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=" + "license": "WTFPL" }, - "wcwidth": { + "node_modules/wcwidth": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", - "requires": { + "license": "MIT", + "dependencies": { "defaults": "^1.0.3" } + }, + "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", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "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" + } } } } diff --git a/package.json b/package.json index 1cbcb9c..c1630a8 100644 --- a/package.json +++ b/package.json @@ -8,16 +8,23 @@ }, "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.4", "sanitize-filename": "^1.6.3", "shortid": "^2.2.15", "tempy": "^0.2.1", - "tui-lib": "^0.3.1" + "shell-escape": "^0.2.0", + "tempy": "^0.2.1", + "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 dde1fbf..7b11a3b 100644 --- a/players.js +++ b/players.js @@ -1,15 +1,22 @@ // stolen from http-music -const { spawn } = require('child_process') -const { commandExists, killProcess, getTimeStrings } = require('./general-util') -const EventEmitter = require('events') -const Socat = require('./socat') -const fs = require('fs') -const util = require('util') - -const unlink = util.promisify(fs.unlink) - -class Player extends EventEmitter { +import { + commandExists, + killProcess, + getTimeStrings, + getTimeStringsFromSec, +} from './general-util.js' + +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' + +import Socat from './socat.js' + +export class Player extends EventEmitter { constructor(processOptions = []) { super() @@ -37,13 +44,14 @@ class Player extends EventEmitter { return this._process } - playFile(file) {} - seekAhead(secs) {} - seekBack(secs) {} - seekTo(timeInSecs) {} - volUp(amount) {} - volDown(amount) {} - setVolume(value) {} + playFile(_file, _startTime) {} + seekAhead(_secs) {} + seekBack(_secs) {} + seekTo(_timeInSecs) {} + seekToStart() {} + volUp(_amount) {} + volDown(_amount) {} + setVolume(_value) {} updateVolume() {} togglePause() {} toggleLoop() {} @@ -86,24 +94,44 @@ class Player extends EventEmitter { } } -module.exports.MPVPlayer = class extends Player { - getMPVOptions(file) { - const opts = ['--no-video', file] +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. + + getMPVOptions(file, startTime) { + 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) { - // 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}' + } - this.process = spawn('mpv', this.getMPVOptions(file).concat(this.processOptions)) + playFile(file, startTime) { + this.process = spawn('mpv', this.getMPVOptions(file, startTime).concat(this.processOptions)) let lastPercent = 0 @@ -113,14 +141,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) { @@ -133,7 +161,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(); @@ -145,22 +173,21 @@ 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)] +export class ControllableMPVPlayer extends MPVPlayer { + getMPVOptions(...args) { + return ['--input-ipc-server=' + this.socat.path, ...super.getMPVOptions(...args)] } - playFile(file) { + playFile(file, startTime) { 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) - const mpv = super.playFile(file) + const mpv = super.playFile(file, startTime) mpv.then(() => this.removeSocket(this.socketPath)) @@ -169,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 @@ -194,6 +221,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) } @@ -253,14 +284,19 @@ module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer { } } -module.exports.SoXPlayer = class extends Player { - playFile(file) { +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). // You don't get keyboard controls such as seeking or volume adjusting // with SoX, though. - this.process = spawn('play', [file].concat(this.processOptions)) + this._file = file + + this.process = spawn('play', [file].concat( + this.processOptions, + startTime ? ['trim', startTime] : [] + )) this.process.stdout.on('data', data => { process.stdout.write(data.toString()) @@ -274,14 +310,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). @@ -309,19 +343,50 @@ 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 = []) { +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 979c6d6..dd1d8c8 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. @@ -169,7 +165,7 @@ function flattenGrouplike(grouplike) { return {items: getFlatTrackList(grouplike)} } -function getFlatTrackList(grouplike) { +export function getFlatTrackList(grouplike) { // Underlying function for flattenGrouplike. Can be used if you just want to // get an array and not a grouplike, too. @@ -182,7 +178,7 @@ function getFlatTrackList(grouplike) { }).reduce((a, b) => a.concat(b), []) } -function getFlatGroupList(grouplike) { +export function getFlatGroupList(grouplike) { // Analogue of getFlatTrackList for groups instead of tracks. Returns a flat // array of all the groups in each level of the provided grouplike. @@ -192,7 +188,7 @@ function getFlatGroupList(grouplike) { .reduce((a, b) => a.concat(b), []) } -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. @@ -206,7 +202,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, @@ -224,12 +220,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 @@ -252,7 +248,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 @@ -273,7 +269,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. @@ -299,7 +295,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.) @@ -329,13 +325,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 @@ -386,13 +382,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) @@ -433,7 +429,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)) @@ -469,7 +465,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 { @@ -477,7 +473,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 @@ -504,12 +500,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!' @@ -520,6 +516,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 @@ -534,14 +535,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 @@ -553,12 +554,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, '') } @@ -606,24 +607,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 @@ -663,12 +664,12 @@ function searchForItem(grouplike, value, preferredStartIndex = -1) { return null } -function getCorrespondingFileForItem(item, extension) { +export function getCorrespondingFileForItem(item, extension) { if (!(item && item.url)) { 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] @@ -688,7 +689,7 @@ function getCorrespondingFileForItem(item, extension) { return null } -function getCorrespondingPlayableForFile(item) { +export function getCorrespondingPlayableForFile(item) { if (!(item && item.url)) { return null } @@ -707,7 +708,7 @@ function getCorrespondingPlayableForFile(item) { return parent.items.find(item => isPlayable(item) && path.basename(item.url, path.extname(item.url)) === basename) } -function getPathScore(path1, path2) { +export function getPathScore(path1, path2) { // This function is basically only used in findTrackObject, but it's kinda // huge and I need to test that it works outside of that context, so I'm // sticking it on the global scope. Feel free to steal for whatever your @@ -836,7 +837,7 @@ function getPathScore(path1, path2) { return scores.reduce((a, b) => a < b ? a : b) } -function getNameScore(name1, name2) { +export function getNameScore(name1, name2) { // Pretty simple algorithm here: we're looking for the longest continuous // series of words which is shared between both names. The score is the // length of that series, so a higher score is better (and a zero score @@ -902,7 +903,7 @@ function getNameScore(name1, name2) { ) } -function findItemObject(referenceData, possibleChoices) { +export function findItemObject(referenceData, possibleChoices) { // Finds the item object in the provided choices which most closely resembles // the provided reference data. This is used for maintaining the identity of // item objects when reloading a playlist (see serialized-backend.js). It's @@ -977,88 +978,23 @@ function findItemObject(referenceData, possibleChoices) { return mostResembles.item } -module.exports = { - parentSymbol, - updatePlaylistFormat, updateGroupFormat, updateTrackFormat, - cloneGrouplike, - filterTracks, - flattenGrouplike, - getFlatTrackList, - getFlatGroupList, - countTotalTracks, - shuffleOrderOfGroups, - reverseOrderOfGroups, - partiallyFlattenGrouplike, collapseGrouplike, - filterGrouplikeByProperty, - filterPlaylistByPathString, filterGrouplikeByPath, - removeGroupByPathString, removeGroupByPath, - getPlaylistTreeString, - getItemPath, getItemPathString, - parsePathString, - getTrackIndexInParent, - getNameWithoutTrackNumber, - searchForItem, - getCorrespondingFileForItem, - getCorrespondingPlayableForFile, - getPathScore, - findItemObject, - isGroup, isTrack, - isOpenable, isPlayable -} - -if (require.main === module) { - console.log(getPathScore(['A', 'B', 'C'], ['A', 'B', 'C'])) - console.log(getPathScore(['A', 'B', 'C'], ['A', 'B', 'C', 'D'])) - console.log(getPathScore(['A', 'B', 'C', 'E'], ['A', 'B', 'C'])) - console.log(getPathScore(['W', 'X'], ['Y', 'Z'])) - console.log(getNameScore('C418 - Vlem', 'Vlem')) - console.log(getNameScore('glimmer', 'glimmer')) - console.log(getNameScore('C418 - Vlem', 'covet - glimmer')) - console.log(findItemObject( - // {name: 'T', downloaderArg: 'foo', path: ['A', 'B', 'C']}, - {name: 'B'}, - // getFlatTrackList( - getFlatGroupList( - updateGroupFormat({items: [ - {id: 1, name: 'T'}, - {id: 2, name: 'T'}, - {id: 3, name: 'T'}, - // {id: 4, name: 'T', downloaderArg: 'foo'}, - {id: 5, name: 'T'}, - {id: 6, name: 'Y', downloaderArg: 'foo'}, - {name: 'A', items: [ - {name: 'B', items: [ - {name: 'C', items: [ - {name: 'T'} - ]}, - {name: 'T'} - ]} - ]} - ]}) - ) - )) - - { - 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'} - ]}) +export function walkSharedStructure(modelGrouplike, ...additionalGrouplikesAndCallback) { + // Recursively traverse (aka "walk") a model grouplike and follow the same + // path through one or more additional grouplikes, running a callback with + // the item at that path from each of the grouplikes (model and additional). - for (let i = 0; i < group.items.length; i++) { - console.log(group.items[i].name, '->', getNameWithoutTrackNumber(group.items[i])) - } - } + const additionalGrouplikes = additionalGrouplikesAndFunction.slice(0, -1) + const callback = additionalGrouplikesAndCallback[additionalGrouplikesAndFunction.length - 1] - { - const group = updateGroupFormat({items: [ - {name: 'BAM #1', downloaderArg: 'x'}, - {name: 'BAM #2', downloaderArg: 'x'}, - {name: 'BAM #3.1 - no', downloaderArg: 'x'} - ]}) + const recursive = (model, ...additional) => { + for (let i = 0; i < model.items.length; i++) { + const modelItem = model.items[i] + const additionalItems = additional.map(a => a.items[i]) + callback(modelItem, ...additionalItems) - for (let i = 0; i < group.items.length; i++) { - console.log(group.items[i].name, '->', getNameWithoutTrackNumber(group.items[i])) + if (isGroup(modelItem)) { + recursive(modelItem, ...additionalItems) + } } } } 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/serialized-backend.js b/serialized-backend.js index a3f02fa..bdac54c 100644 --- a/serialized-backend.js +++ b/serialized-backend.js @@ -21,7 +21,7 @@ 'use strict' -const { +import { isGroup, isTrack, findItemObject, @@ -29,7 +29,7 @@ const { getFlatGroupList, getFlatTrackList, getItemPath -} = require('./playlist-utils') +} from './playlist-utils.js' const referenceDataSymbol = Symbol('Restored reference data') @@ -43,7 +43,7 @@ function getPlayerInfo(queuePlayer) { } } -function saveBackend(backend) { +export function saveBackend(backend) { return { queuePlayers: backend.queuePlayers.map(QP => ({ id: QP.id, @@ -55,7 +55,7 @@ function saveBackend(backend) { } } -async function restoreBackend(backend, data) { +export async function restoreBackend(backend, data) { if (data.queuePlayers) { if (data.queuePlayers.length === 0) { return @@ -90,18 +90,10 @@ async function restoreBackend(backend, data) { async function restorePlayingTrack(queuePlayer, playedTrack, playerInfo) { const QP = queuePlayer await QP.stopPlaying() - QP.play(playedTrack, true) - QP.once('received time data', () => { - if (QP.playingTrack === playedTrack) { - QP.player.seekTo(playerInfo.time) - if (!playerInfo.isPaused) { - QP.player.togglePause() - } - } - }) + QP.play(playedTrack, playerInfo.time || 0, playerInfo.isPaused) } -function updateRestoredTracksUsingPlaylists(backend, playlists) { +export function updateRestoredTracksUsingPlaylists(backend, playlists) { // Utility function to restore the "identities" of tracks (i.e. which objects // they are represented by) queued or playing in the provided backend, // pulling possible track identities from the provided playlists. @@ -159,7 +151,7 @@ function updateRestoredTracksUsingPlaylists(backend, playlists) { } } -function saveItemReference(item) { +export function saveItemReference(item) { // Utility function to generate reference data for a track or grouplike, // according to the format taken by findItemObject. @@ -182,7 +174,7 @@ function saveItemReference(item) { } } -function restoreNewItem(referenceData, playlists) { +export function restoreNewItem(referenceData, playlists) { // Utility function to restore a new item. If you're restoring tracks // already present in a backend, use the specific function for that, // updateRestoredTracksUsingPlaylists. @@ -227,19 +219,10 @@ function restoreNewItem(referenceData, playlists) { } } -function getWaitingTrackData(queuePlayer) { +export function getWaitingTrackData(queuePlayer) { // Utility function to get reference data for the track which is currently // waiting to be played, once a resembling track is found. This should only // be used to reflect that data in the user interface. return (queuePlayer[referenceDataSymbol] || {}).playingTrack } - -Object.assign(module.exports, { - saveBackend, - restoreBackend, - updateRestoredTracksUsingPlaylists, - saveItemReference, - restoreNewItem, - getWaitingTrackData -}) 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/socket.js b/socket.js index a40dc97..59f70d9 100644 --- a/socket.js +++ b/socket.js @@ -17,40 +17,41 @@ // library from there. This would be handy for people with a VPN with its own // hostname and firewall protections! -'use strict' // single quotes & no semicolons time babey +// single quotes & no semicolons time babey -// This is expected to be the same across both the client and the server. -// There will probably be inconsistencies between sender clients and receiving -// clients / the server otherwise. -const DEFAULT_NICKNAME = '(Unnamed)' - -const originalSymbol = Symbol('Original item') - -const EventEmitter = require('events') -const net = require('net') -const shortid = require('shortid') +import EventEmitter from 'node:events' +import net from 'node:net' -const { - saveBackend, - restoreBackend, - saveItemReference, - restoreNewItem, - updateRestoredTracksUsingPlaylists -} = require('./serialized-backend') +import shortid from 'shortid' -const { +import { getTimeStringsFromSec, parseWithoutPrototype, - silenceEvents -} = require('./general-util') + silenceEvents, +} from './general-util.js' -const { +import { parentSymbol, updateGroupFormat, updateTrackFormat, isTrack, - isGroup -} = require('./playlist-utils') + isGroup, +} from './playlist-utils.js' + +import { + restoreBackend, + restoreNewItem, + saveBackend, + saveItemReference, + updateRestoredTracksUsingPlaylists, +} from './serialized-backend.js' + +// This is expected to be the same across both the client and the server. +// There will probably be inconsistencies between sender clients and receiving +// clients / the server otherwise. +const DEFAULT_NICKNAME = '(Unnamed)' + +export const originalSymbol = Symbol('Original item') function serializePartySource(item) { // Turn an item into a sanitized, compact format for sharing with the server @@ -156,7 +157,7 @@ function validateCommand(command) { switch (command.sender) { case 'server': switch (command.code) { - case 'initialize-party': + case 'initialize party': return ( typeof command.backend === 'object' && typeof command.socketInfo === 'object' && @@ -165,24 +166,24 @@ function validateCommand(command) { Array.isArray(info.sharedSources) )) ) - case 'set-socket-id': + case 'set socket id': return typeof command.socketId === 'string' } // No break here; servers can send commands which typically come from // clients too. case 'client': switch (command.code) { - case 'announce-join': + case 'announce join': return true - case 'clear-queue': + case 'clear queue': return typeof command.queuePlayer === 'string' - case 'clear-queue-past': - case 'clear-queue-up-to': + case 'clear queue past': + case 'clear queue up to': return ( typeof command.queuePlayer === 'string' && isItemRef(command.track) ) - case 'distribute-queue': + case 'distribute queue': return ( typeof command.queuePlayer === 'string' && isItemRef(command.topItem) && @@ -218,26 +219,26 @@ function validateCommand(command) { ) )) ) - case 'restore-queue': + case 'restore queue': return ( typeof command.queuePlayer === 'string' && Array.isArray(command.tracks) && command.tracks.every(track => isItemRef(track)) && ['shuffle'].includes(command.why) ) - case 'seek-to': + case 'seek to': return ( typeof command.queuePlayer === 'string' && typeof command.time === 'number' ) - case 'set-nickname': + case 'set nickname': return ( typeof command.nickname === 'string' && typeof command.oldNickname === 'string' && command.nickname.length >= 1 && command.nickname.length <= 12 ) - case 'set-pause': + case 'set pause': return ( typeof command.queuePlayer === 'string' && typeof command.paused === 'boolean' && @@ -246,21 +247,21 @@ function validateCommand(command) { command.sender === 'server' ) || !command.startingTrack ) - case 'share-with-party': + case 'share with party': return ( typeof command.item === 'string' || Array.isArray(command.item) ) case 'status': return ( - command.status === 'done-playing' || + command.status === 'done playing' || ( - command.status === 'ready-to-resume' && + command.status === 'ready to resume' && typeof command.queuePlayer === 'string' ) || - command.status === 'sync-playback' + command.status === 'sync playback' ) - case 'stop-playing': + case 'stop playing': return typeof command.queuePlayer === 'string' case 'unqueue': return ( @@ -297,7 +298,7 @@ function perLine(handleLine) { } } -function makeSocketServer() { +export function makeSocketServer() { // The socket server has two functions: to maintain a "canonical" backend // and synchronize newly connected clients with the relevent data in this // backend, and to receive command data from clients and relay this to @@ -365,8 +366,8 @@ function makeSocketServer() { if (!socketInfo.hasAnnouncedJoin) { if (![ - 'announce-join', - 'set-nickname' + 'announce join', + 'set nickname' ].includes(command.code)) { return } @@ -377,7 +378,7 @@ function makeSocketServer() { if (command.code === 'status') { switch (command.status) { - case 'done-playing': { + case 'done playing': { const doneSockets = donePlaying[command.queuePlayer] if (doneSockets && !doneSockets.includes(socketId)) { doneSockets.push(socketId) @@ -391,17 +392,17 @@ function makeSocketServer() { } break } - case 'ready-to-resume': { + case 'ready to resume': { const readySockets = readyToResume[command.queuePlayer] if (readySockets && !readySockets.includes(socketId)) { readySockets.push(socketId) if (readySockets.length === Object.keys(socketMap).length) { - const QP = server.canonicalBackend.queuePlayers.find(QP => QP.id === command.queuePlayer) - silenceEvents(QP, ['set-pause'], () => QP.setPause(false)) + // const QP = server.canonicalBackend.queuePlayers.find(QP => QP.id === command.queuePlayer) + // silenceEvents(QP, ['set pause'], () => QP.setPause(false)) for (const socket of Object.values(socketMap)) { socket.write(serializeCommandToData({ sender: 'server', - code: 'set-pause', + code: 'set pause', queuePlayer: command.queuePlayer, startingTrack: true, paused: false @@ -413,18 +414,18 @@ function makeSocketServer() { } break } - case 'sync-playback': + case 'sync playback': for (const QP of server.canonicalBackend.queuePlayers) { if (QP.timeData) { socket.write(serializeCommandToData({ sender: 'server', - code: 'seek-to', + code: 'seek to', queuePlayer: QP.id, time: QP.timeData.curSecTotal }) + '\n') socket.write(serializeCommandToData({ sender: 'server', - code: 'set-pause', + code: 'set pause', queuePlayer: QP.id, startingTrack: true, paused: QP.player.isPaused @@ -442,31 +443,31 @@ function makeSocketServer() { readyToResume[command.queuePlayer] = [] } - // If it's a 'set-nickname' command, save the nickname. + // If it's a 'set nickname' command, save the nickname. // Also attach the old nickname for display in log messages. - if (command.code === 'set-nickname') { + if (command.code === 'set nickname') { command.oldNickname = socketInfo.nickname command.senderNickname = socketInfo.nickname socketInfo.nickname = command.nickname } - // If it's a 'share-with-party' command, keep track of the item being + // If it's a 'share with party' command, keep track of the item being // shared, so we can synchronize newly joining sockets with it. - if (command.code === 'share-with-party') { + if (command.code === 'share with party') { const { sharedSources } = socketInfoMap[socketId] sharedSources.push(command.item) } - // If it's an 'announce-join' command, mark the variable for this! + // If it's an 'announce join' command, mark the variable for this! - if (command.code === 'announce-join') { + if (command.code === 'announce join') { socketInfo.hasAnnouncedJoin = true; } // If the socket hasn't announced its joining yet, don't relay the - // command. (Since hasAnnouncedJoin gets set above, 'announce-join' + // command. (Since hasAnnouncedJoin gets set above, 'announce join' // will pass this condition.) if (!socketInfo.hasAnnouncedJoin) { @@ -492,13 +493,13 @@ function makeSocketServer() { socket.write(serializeCommandToData({ sender: 'server', - code: 'set-socket-id', + code: 'set socket id', socketId }) + '\n') socket.write(serializeCommandToData({ sender: 'server', - code: 'initialize-party', + code: 'initialize party', backend: savedBackend, socketInfo: socketInfoMap }) + '\n') @@ -507,7 +508,7 @@ function makeSocketServer() { return server } -function makeSocketClient() { +export function makeSocketClient() { // The socket client connects to a server and sends/receives commands to/from // that server. This doesn't actually connect the socket to a port/host; that // is the caller's responsibility (use client.socket.connect()). @@ -520,7 +521,7 @@ function makeSocketClient() { client.sendCommand = function(command) { const data = serializeCommandToData(command) client.socket.write(data + '\n') - client.emit('sent-command', command) + client.emit('sent command', command) } client.socket.on('data', perLine(line => { @@ -544,7 +545,7 @@ function makeSocketClient() { return client } -function attachBackendToSocketClient(backend, client) { +export function attachBackendToSocketClient(backend, client) { // All actual logic for instances of the mtui backend interacting with each // other through commands lives here. @@ -582,22 +583,22 @@ function attachBackendToSocketClient(backend, client) { let isVerbose = false switch (command.code) { - case 'announce-join': + case 'announce join': actionmsg = `joined the party` break - case 'clear-queue': + case 'clear queue': actionmsg = 'cleared the queue' break - case 'clear-queue-past': + case 'clear queue past': actionmsg = `cleared the queue past ${itemToMessage(command.track)}` break - case 'clear-queue-up-to': + case 'clear queue up to': actionmsg = `cleared the queue up to ${itemToMessage(command.track)}` break - case 'distribute-queue': + case 'distribute queue': actionmsg = `distributed ${itemToMessage(command.topItem)} across the queue ${command.opts.how}` break - case 'initialize-party': + case 'initialize party': return case 'play': actionmsg = `started playing ${itemToMessage(command.track)}` @@ -612,37 +613,37 @@ function attachBackendToSocketClient(backend, client) { actionmsg = `queued ${itemToMessage(command.topItem)}` + afterMessage break } - case 'restore-queue': + case 'restore queue': if (command.why === 'shuffle') { actionmsg = 'shuffled the queue' } break - case 'share-with-party': + case 'share with party': // TODO: This isn't an outrageously expensive operation, but it still // seems a little unnecessary to deserialize it here if we also do that // when actually processing the source? actionmsg = `shared ${itemToMessage(deserializePartySource(command.item))} with the party` break - case 'seek-to': + case 'seek to': // TODO: the second value here should be the duration of the track // (this will make values like 0:0x:yy / 1:xx:yy appear correctly) actionmsg = `seeked to ${getTimeStringsFromSec(command.time, command.time).timeDone}` mayCombine = true break - case 'set-nickname': + case 'set nickname': actionmsg = `updated their nickname (from ${nickToMessage(command.oldNickname)})` senderNickname = command.nickname break - case 'set-socket-id': + case 'set socket id': return - case 'set-pause': + case 'set pause': if (command.paused) { actionmsg = 'paused the player' } else { actionmsg = 'resumed the player' } break - case 'stop-playing': + case 'stop playing': actionmsg = 'stopped the player' break case 'unqueue': @@ -651,13 +652,13 @@ function attachBackendToSocketClient(backend, client) { case 'status': isVerbose = true switch (command.status) { - case 'ready-to-resume': + case 'ready to resume': actionmsg = `is ready to play!` break - case 'done-playing': + case 'done playing': actionmsg = `has finished playing` break - case 'sync-playback': + case 'sync playback': actionmsg = `synced playback with the server` break default: @@ -676,7 +677,7 @@ function attachBackendToSocketClient(backend, client) { }) } - client.on('sent-command', command => { + client.on('sent command', command => { command.senderNickname = client.nickname logCommand(command) }) @@ -686,7 +687,7 @@ function attachBackendToSocketClient(backend, client) { switch (command.sender) { case 'server': switch (command.code) { - case 'set-socket-id': + case 'set socket id': client.socketId = command.socketId socketInfoMap[command.socketId] = { nickname: client.nickname, @@ -694,7 +695,7 @@ function attachBackendToSocketClient(backend, client) { } backend.loadSharedSources(command.socketId, sharedSources) return - case 'initialize-party': + case 'initialize party': for (const [ socketId, info ] of Object.entries(command.socketInfo)) { const nickname = info.nickname @@ -714,11 +715,11 @@ function attachBackendToSocketClient(backend, client) { backend.loadSharedSources(socketId, sharedSources) } await restoreBackend(backend, command.backend) - backend.on('playing', QP => { - QP.once('received time data', () => { - client.sendCommand({code: 'status', status: 'sync-playback'}) - }) - }) + // backend.on('QP: playing', QP => { + // QP.once('received time data', () => { + // client.sendCommand({code: 'status', status: 'sync playback'}) + // }) + // }) return } // Again, no break. Client commands can come from the server. @@ -729,7 +730,7 @@ function attachBackendToSocketClient(backend, client) { ) switch (command.code) { - case 'announce-join': { + case 'announce join': { const sharedSources = { name: namePartySources(command.senderNickname), isPartySources: true, @@ -742,21 +743,21 @@ function attachBackendToSocketClient(backend, client) { backend.loadSharedSources(command.senderSocketId, sharedSources) return } - case 'clear-queue': - if (QP) silenceEvents(QP, ['clear-queue'], () => QP.clearQueue()) + case 'clear queue': + if (QP) silenceEvents(QP, ['clear queue'], () => QP.clearQueue()) return - case 'clear-queue-past': - if (QP) silenceEvents(QP, ['clear-queue-past'], () => QP.clearQueuePast( + case 'clear queue past': + if (QP) silenceEvents(QP, ['clear queue past'], () => QP.clearQueuePast( restoreNewItem(command.track, getPlaylistSources()) )) return - case 'clear-queue-up-to': - if (QP) silenceEvents(QP, ['clear-queue-up-to'], () => QP.clearQueueUpTo( + case 'clear queue up to': + if (QP) silenceEvents(QP, ['clear queue up to'], () => QP.clearQueueUpTo( restoreNewItem(command.track, getPlaylistSources()) )) return - case 'distribute-queue': - if (QP) silenceEvents(QP, ['distribute-queue'], () => QP.distributeQueue( + case 'distribute queue': + if (QP) silenceEvents(QP, ['distribute queue'], () => QP.distributeQueue( restoreNewItem(command.topItem), { how: command.opts.how, @@ -769,7 +770,7 @@ function attachBackendToSocketClient(backend, client) { QP.once('received time data', data => { client.sendCommand({ code: 'status', - status: 'ready-to-resume', + status: 'ready to resume', queuePlayer: QP.id }) }) @@ -787,24 +788,24 @@ function attachBackendToSocketClient(backend, client) { } )) return - case 'restore-queue': + case 'restore queue': if (QP) { QP.replaceAllItems(command.tracks.map( refData => restoreNewItem(refData, getPlaylistSources()) )) } return - case 'seek-to': - if (QP) silenceEvents(QP, ['seek-to'], () => QP.seekTo(command.time)) + case 'seek to': + if (QP) silenceEvents(QP, ['seek to'], () => QP.seekTo(command.time)) return - case 'set-nickname': { + case 'set nickname': { const info = socketInfoMap[command.senderSocketId] info.nickname = command.senderNickname info.sharedSources.name = namePartySources(command.senderNickname) backend.sharedSourcesUpdated(client.socketId, info.sharedSources) return } - case 'set-pause': { + case 'set pause': { // TODO: there's an event leak here when toggling pause while // nothing is playing let playingThisTrack = true @@ -813,19 +814,19 @@ function attachBackendToSocketClient(backend, client) { }) setTimeout(() => { if (playingThisTrack) { - if (QP) silenceEvents(QP, ['set-pause'], () => QP.setPause(command.paused)) + if (QP) silenceEvents(QP, ['set pause'], () => QP.setPause(command.paused)) } }, command.startingTrack ? 500 : 0) return } - case 'share-with-party': { + case 'share with party': { const { sharedSources } = socketInfoMap[command.senderSocketId] const deserialized = deserializePartySource(command.item, sharedSources) sharedSources.items.push(deserialized) backend.sharedSourcesUpdated(command.senderSocketId, sharedSources) return } - case 'stop-playing': + case 'stop playing': if (QP) silenceEvents(QP, ['playing'], () => QP.stopPlaying()) return case 'unqueue': @@ -838,47 +839,47 @@ function attachBackendToSocketClient(backend, client) { } }) - backend.on('clear-queue', queuePlayer => { + backend.on('QP: clear queue', queuePlayer => { client.sendCommand({ - code: 'clear-queue', + code: 'clear queue', queuePlayer: queuePlayer.id }) }) - backend.on('clear-queue-past', (queuePlayer, track) => { + backend.on('QP: clear queue past', (queuePlayer, track) => { client.sendCommand({ - code: 'clear-queue-past', + code: 'clear queue past', queuePlayer: queuePlayer.id, track: saveItemReference(track) }) }) - backend.on('clear-queue-up-to', (queuePlayer, track) => { + backend.on('QP: clear queue up to', (queuePlayer, track) => { client.sendCommand({ - code: 'clear-queue-up-to', + code: 'clear queue up to', queuePlayer: queuePlayer.id, track: saveItemReference(track) }) }) - backend.on('distribute-queue', (queuePlayer, topItem, opts) => { + backend.on('QP: distribute queue', (queuePlayer, topItem, opts) => { client.sendCommand({ - code: 'distribute-queue', + code: 'distribute queue', queuePlayer: queuePlayer.id, topItem: saveItemReference(topItem), opts }) }) - backend.on('done playing', queuePlayer => { + backend.on('QP: done playing', queuePlayer => { client.sendCommand({ code: 'status', - status: 'done-playing', + status: 'done playing', queuePlayer: queuePlayer.id }) }) - backend.on('playing', (queuePlayer, track) => { + backend.on('QP: playing', (queuePlayer, track) => { if (track) { client.sendCommand({ code: 'play', @@ -888,19 +889,20 @@ function attachBackendToSocketClient(backend, client) { queuePlayer.once('received time data', data => { client.sendCommand({ code: 'status', - status: 'ready-to-resume', + status: 'ready to resume', queuePlayer: queuePlayer.id }) }) } else { client.sendCommand({ - code: 'stop-playing', + code: 'stop playing', queuePlayer: queuePlayer.id }) } }) - backend.on('queue', (queuePlayer, topItem, afterItem, opts) => { + let n = 0 + backend.on('QP: queue', (queuePlayer, topItem, afterItem, opts) => { client.sendCommand({ code: 'queue', queuePlayer: queuePlayer.id, @@ -912,41 +914,41 @@ function attachBackendToSocketClient(backend, client) { function handleSeek(queuePlayer) { client.sendCommand({ - code: 'seek-to', + code: 'seek to', queuePlayer: queuePlayer.id, time: queuePlayer.time }) } - backend.on('seek-ahead', handleSeek) - backend.on('seek-back', handleSeek) - backend.on('seek-to', handleSeek) + backend.on('QP: seek ahead', handleSeek) + backend.on('QP: seek back', handleSeek) + backend.on('QP: seek to', handleSeek) backend.on('set party nickname', nickname => { let oldNickname = client.nickname sharedSources.name = namePartySources(nickname) client.nickname = nickname - client.sendCommand({code: 'set-nickname', nickname, oldNickname}) + client.sendCommand({code: 'set nickname', nickname, oldNickname}) }) - backend.on('shuffle-queue', queuePlayer => { + backend.on('QP: shuffle queue', queuePlayer => { client.sendCommand({ - code: 'restore-queue', + code: 'restore queue', why: 'shuffle', queuePlayer: queuePlayer.id, tracks: queuePlayer.queueGrouplike.items.map(saveItemReference) }) }) - backend.on('toggle-pause', queuePlayer => { + backend.on('QP: toggle pause', queuePlayer => { client.sendCommand({ - code: 'set-pause', + code: 'set pause', queuePlayer: queuePlayer.id, paused: queuePlayer.player.isPaused }) }) - backend.on('unqueue', (queuePlayer, topItem) => { + backend.on('QP: unqueue', (queuePlayer, topItem) => { client.sendCommand({ code: 'unqueue', queuePlayer: queuePlayer.id, @@ -956,7 +958,7 @@ function attachBackendToSocketClient(backend, client) { backend.on('announce join party', () => { client.sendCommand({ - code: 'announce-join' + code: 'announce join' }) }) @@ -974,25 +976,17 @@ function attachBackendToSocketClient(backend, client) { updateRestoredTracksUsingPlaylists(backend, getPlaylistSources()) client.sendCommand({ - code: 'share-with-party', + code: 'share with party', item: serialized }) } }) } -function attachSocketServerToBackend(server, backend) { +export function attachSocketServerToBackend(server, backend) { // Unlike the function for attaching a backend to follow commands from a // client (attachBackendToSocketClient), this function is minimalistic. // It just sets the associated "canonical" backend. Actual logic for // de/serialization lives in serialized-backend.js. server.canonicalBackend = backend } - -Object.assign(module.exports, { - originalSymbol, - makeSocketServer, - makeSocketClient, - attachBackendToSocketClient, - attachSocketServerToBackend -}) 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 dab8e29..da6c9e6 100644 --- a/todo.txt +++ b/todo.txt @@ -577,9 +577,6 @@ 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: Tabber tab list should be accessible via tab (key). TODO: Show current index and number of tabs beside tabber tab list. @@ -616,3 +613,128 @@ TODO: Show debug log messages when validating a command fails! On both server TODO: Naming a shared sources list should definitely happen in a function. (Done!) + +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!) + +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! + (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 + progress bar itself? + +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. + (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 + +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. + (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!) + +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!) + +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: <downloaderArg>" 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. + +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? + +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. + +TODO: GHOST BACKEND for socket server... the main thing is syncing duration + data. It sucks to have the player, like, actually be tied to a specific + instance of MPV or whatever, so we'd use a ~ghost player~ which supports + all the usual interfaces and lies about its current playback time. Yay! diff --git a/ui.js b/ui.js index da6cff9..4a92ebe 100644 --- a/ui.js +++ b/ui.js @@ -1,24 +1,45 @@ // The UI in MTUI! Interfaces with the backend to form the complete mtui app. -'use strict' +import {spawn} from 'node:child_process' +import {readFile, writeFile} from 'node:fs/promises' +import path from 'node:path' +import url from 'node:url' -const { getAllCrawlersForArg } = require('./crawlers') -const processSmartPlaylist = require('./smart-playlist') -const UndoManager = require('./undo-manager') +import {orderBy} from 'natural-orderby' +import open from 'open' -const { +import {Button, Form, ListScrollForm, TextInput} from 'tui-lib/ui/controls' +import {Dialog} from 'tui-lib/ui/dialogs' +import {Label, Pane, WrapLabel} from 'tui-lib/ui/presentation' +import {DisplayElement, FocusElement} from 'tui-lib/ui/primitives' + +import * as ansi from 'tui-lib/util/ansi' +import telc from 'tui-lib/util/telchars' +import unic from 'tui-lib/util/unichars' + +import {getAllCrawlersForArg} from './crawlers.js' +import {originalSymbol} from './socket.js' +import processSmartPlaylist from './smart-playlist.js' +import UndoManager from './undo-manager.js' + +import { commandExists, + getSecFromTimestamp, getTimeStringsFromSec, promisifyProcess, - shuffleArray -} = require('./general-util') + shuffleArray, +} from './general-util.js' -const { +import { cloneGrouplike, + collapseGrouplike, countTotalTracks, + findItemObject, flattenGrouplike, getCorrespondingFileForItem, getCorrespondingPlayableForFile, + getFlatTrackList, + getFlatGroupList, getItemPath, getNameWithoutTrackNumber, isGroup, @@ -28,55 +49,19 @@ const { parentSymbol, reverseOrderOfGroups, searchForItem, - shuffleOrderOfGroups -} = require('./playlist-utils') + shuffleOrderOfGroups, +} from './playlist-utils.js' -const { +import { updateRestoredTracksUsingPlaylists, getWaitingTrackData -} = require('./serialized-backend') - -const { - ui: { - Dialog, - DisplayElement, - Label, - Pane, - WrapLabel, - form: { - Button, - FocusElement, - Form, - ListScrollForm, - TextInput, - } - }, - util: { - ansi, - telchars: telc, - unichars: unic, - } -} = require('tui-lib') - -const { - originalSymbol -} = require('./socket') +} from './serialized-backend.js' /* text editor features disabled because theyre very much incomplete and havent * gotten much use from me or anyone afaik! const TuiTextEditor = require('tui-text-editor') */ -const { promisify } = require('util') -const { spawn } = require('child_process') -const { orderBy } = require('natural-orderby') -const fs = require('fs') -const open = require('open') -const path = require('path') -const url = require('url') -const readFile = promisify(fs.readFile) -const writeFile = promisify(fs.writeFile) - const input = {} const keyBindings = [ @@ -116,6 +101,8 @@ const keyBindings = [ // ['isFocusLabels', 'L', {caseless: false}], // todo: better key? to let isToggleLoop be caseless again ['isSelectUp', telc.isShiftUp], ['isSelectDown', telc.isShiftDown], + ['isNextThemeColor', 'c', {caseless: false}], + ['isPreviousThemeColor', 'C', {caseless: false}], ['isPreviousPlayer', telc.isMetaUp], ['isPreviousPlayer', [0x1b, 'p']], @@ -191,7 +178,7 @@ telc.isRight = input.isRight telc.isSelect = input.isSelect telc.isBackspace = input.isBackspace -class AppElement extends FocusElement { +export default class AppElement extends FocusElement { constructor(backend, config = {}) { super() @@ -208,7 +195,8 @@ class AppElement extends FocusElement { canControlQueuePlayers: true, canProcessMetadata: true, canSuspend: true, - menubarColor: 4, // blue + themeColor: 4, // blue + seekToStartThreshold: 3, showTabberPane: true, stopPlayingUponQuit: true, showPartyControls: false @@ -220,6 +208,8 @@ class AppElement extends FocusElement { this.cachedMarkStatuses = new Map() this.editMode = false + this.timestampDictionary = new WeakMap() + // We add this is a child later (so that it's on top of every element). this.menuLayer = new DisplayElement() this.menuLayer.clickThrough = true @@ -228,7 +218,8 @@ class AppElement extends FocusElement { this.menubar = new Menubar(this.showContextMenu) this.addChild(this.menubar) - this.menubar.color = this.config.menubarColor + this.setThemeColor(this.config.themeColor) + this.menubar.on('color', color => this.setThemeColor(color)) this.tabberPane = new Pane() this.addChild(this.tabberPane) @@ -257,7 +248,7 @@ class AppElement extends FocusElement { this.logPane.addChild(this.log) this.logPane.visible = false - this.log.on('log-message', () => { + this.log.on('log message', () => { this.logPane.visible = true this.fixLayout() }) @@ -283,7 +274,7 @@ class AppElement extends FocusElement { this.queueTimeLabel = new Label('') this.queuePane.addChild(this.queueTimeLabel) - this.queueListingElement.on('select', item => this.updateQueueLengthLabel()) + this.queueListingElement.on('select', _item => this.updateQueueLengthLabel()) this.queueListingElement.on('open', item => this.openSpecialOrThroughSystem(item)) this.queueListingElement.on('queue', item => this.play(item)) this.queueListingElement.on('remove', item => this.unqueue(item)) @@ -349,6 +340,7 @@ class AppElement extends FocusElement { {value: 'reverse', label: 'Reverse all'}, {value: 'reverse-groups', label: 'Reverse order of groups'}, {value: 'alphabetic', label: 'Alphabetically'}, + {value: 'alphabetic-groups', label: 'Alphabetize order of groups'}, {value: 'normal', label: 'In order'} ], this.showContextMenu) @@ -369,15 +361,17 @@ class AppElement extends FocusElement { return [ {label: playingTrack ? `("${playingTrack.name}")` : '(No track playing.)'}, {divider: true}, + {element: this.volumeSlider}, + {divider: true}, playingTrack && {element: this.playingControl}, - {element: this.loopingControl}, - {element: this.loopQueueControl}, - {element: this.pauseNextControl}, + playingTrack && {element: this.loopingControl}, + playingTrack && {element: this.pauseNextControl}, {element: this.autoDJControl}, - {element: this.volumeSlider}, {divider: true}, previous && {label: `Previous (${previous.name})`, action: () => this.SQP.playPrevious(playingTrack)}, next && {label: `Next (${next.name})`, action: () => this.SQP.playNext(playingTrack)}, + !next && this.SQP.queueEndMode === 'loop' && + {label: `Next (loop queue)`, action: () => this.SQP.playNext(playingTrack)}, next && {label: '- Play later', action: () => this.playLater(next)} ] }}, @@ -388,6 +382,8 @@ class AppElement extends FocusElement { return [ {label: `(Queue - ${curIndex >= 0 ? `${curIndex + 1}/` : ''}${items.length} items.)`}, {divider: true}, + {element: this.loopModeControl}, + {divider: true}, items.length && {label: 'Shuffle', action: () => this.shuffleQueue()}, items.length && {label: 'Clear', action: () => this.clearQueue()} ] @@ -421,6 +417,20 @@ class AppElement extends FocusElement { getEnabled: () => this.config.canControlPlayback }) + this.loopModeControl = new InlineListPickerElement('Loop queue?', [ + {value: 'end', label: 'Don\'t loop'}, + {value: 'loop', label: 'Loop (same order)'}, + {value: 'shuffle', label: 'Loop (shuffle)'} + ], { + setValue: val => { + if (this.SQP) { + this.SQP.queueEndMode = val + } + }, + getValue: () => this.SQP && this.SQP.queueEndMode, + showContextMenu: this.showContextMenu + }) + this.pauseNextControl = new ToggleControl('Pause when this track ends?', { setValue: val => this.SQP.setPauseNextTrack(val), getValue: () => this.SQP.pauseNextTrack, @@ -441,7 +451,7 @@ class AppElement extends FocusElement { this.autoDJControl = new ToggleControl('Enable Auto-DJ?', { setValue: val => (this.enableAutoDJ = val), - getValue: val => this.enableAutoDJ, + getValue: () => this.enableAutoDJ, getEnabled: () => this.config.canControlPlayback }) @@ -497,10 +507,6 @@ class AppElement extends FocusElement { PIE.on('seek back', () => PIE.queuePlayer.seekBack(5)) PIE.on('seek ahead', () => PIE.queuePlayer.seekAhead(5)) PIE.on('toggle pause', () => PIE.queuePlayer.togglePause()) - - queuePlayer.on('received time data', this.handleReceivedTimeData) - queuePlayer.on('playing details', this.handlePlayingDetails) - queuePlayer.on('queue updated', this.handleQueueUpdated) } removeQueuePlayerListenersAndUI(queuePlayer, updateUI = true) { @@ -527,30 +533,39 @@ class AppElement extends FocusElement { this.queuePlayersToActOn.splice(index, 1) } - queuePlayer.removeListener('receivedTimeData', this.handleReceivedTimeData) - queuePlayer.removeListener('playing details', this.handlePlayingDetails) - queuePlayer.removeListener('queue updated', this.handleQueueUpdated) queuePlayer.stopPlaying() } attachBackendListeners() { + // Backend-specialized listeners this.backend.on('processMetadata progress', this.handleProcessMetadataProgress) this.backend.on('added queue player', this.handleAddedQueuePlayer) this.backend.on('removed queue player', this.handleRemovedQueuePlayer) this.backend.on('log message', this.handleLogMessage) this.backend.on('got shared sources', this.handleGotSharedSources) this.backend.on('shared sources updated', this.handleSharedSourcesUpdated) - this.backend.on('set-loop-queue-at-end', this.handleSetLoopQueueAtEnd) + this.backend.on('set loop queue at end', this.handleSetLoopQueueAtEnd) + + // Backend as queue player proxy listeners + this.backend.on('QP: playing details', this.handlePlayingDetails) + this.backend.on('QP: received time data', this.handleReceivedTimeData) + this.backend.on('QP: queue updated', this.handleQueueUpdated) } removeBackendListeners() { + // Backend-specialized listeners this.backend.removeListener('processMetadata progress', this.handleProcessMetadataProgress) this.backend.removeListener('added queue player', this.handleAddedQueuePlayer) this.backend.removeListener('removed queue player', this.handleRemovedQueuePlayer) this.backend.removeListener('log message', this.handleLogMessage) this.backend.removeListener('got shared sources', this.handleGotSharedSources) this.backend.removeListener('shared sources updated', this.handleSharedSourcesUpdated) - this.backend.removeListener('set-loop-queue-at-end', this.handleSetLoopQueueAtEnd) + this.backend.removeListener('set loop queue at end', this.handleSetLoopQueueAtEnd) + + // Backend as queue player proxy listeners + this.backend.removeListener('QP: playing details', this.handlePlayingDetails) + this.backend.removeListener('QP: received time data', this.handleReceivedTimeData) + this.backend.removeListener('QP: queue updated', this.handleQueueUpdated) } handleAddedQueuePlayer(queuePlayer) { @@ -589,7 +604,7 @@ class AppElement extends FocusElement { this.updateQueueLengthLabel() } - async handlePlayingDetails(track, oldTrack, queuePlayer) { + async handlePlayingDetails(queuePlayer, track, oldTrack, startTime) { const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer) if (PIE) { PIE.updateTrack() @@ -597,11 +612,21 @@ class AppElement extends FocusElement { if (queuePlayer === this.SQP) { this.updateQueueLengthLabel() + this.queueListingElement.collapseTimestamps(oldTrack) if (track && this.queueListingElement.currentItem === oldTrack) { this.queueListingElement.selectAndShow(track) } } + // Unfortunately, there isn't really any reliable way to make these work if + // the containing queue isn't of the selected queue player. + const timestampData = track && this.getTimestampData(track) + if (timestampData && queuePlayer === this.SQP) { + if (this.queueListingElement.currentItem === track) { + this.queueListingElement.selectTimestampAtSec(track, startTime) + } + } + if (track && this.enableAutoDJ) { queuePlayer.setVolumeMultiplier(0.5); const message = 'now playing: ' + getNameWithoutTrackNumber(track); @@ -614,7 +639,7 @@ class AppElement extends FocusElement { } } - handleReceivedTimeData(data, queuePlayer) { + handleReceivedTimeData(queuePlayer, timeData, oldTimeData) { const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer) if (PIE) { PIE.updateProgress() @@ -622,6 +647,7 @@ class AppElement extends FocusElement { if (queuePlayer === this.SQP) { this.updateQueueLengthLabel() + this.updateQueueSelection(timeData, oldTimeData) } } @@ -715,7 +741,7 @@ class AppElement extends FocusElement { this.tabber.addTab(grouplikeListing) this.tabber.selectTab(grouplikeListing) - grouplikeListing.on('browse', item => grouplikeListing.loadGrouplike(item)) + grouplikeListing.on('browse', item => this.browse(grouplikeListing, item)) grouplikeListing.on('download', item => this.SQP.download(item)) grouplikeListing.on('open', item => this.openSpecialOrThroughSystem(item)) grouplikeListing.on('queue', (item, opts) => this.handleQueueOptions(item, opts)) @@ -723,7 +749,7 @@ class AppElement extends FocusElement { const updateListingsFor = item => { for (const grouplikeListing of this.tabber.tabberElements) { if (grouplikeListing.grouplike === item) { - grouplikeListing.loadGrouplike(item, false) + this.browse(grouplikeListing, item, false) } } } @@ -811,12 +837,13 @@ class AppElement extends FocusElement { // Sets up event listeners that are common to ordinary grouplike listings // (made by newGrouplikeListing) as well as the queue grouplike listing. - grouplikeListing.pathElement.on('select', (item, child) => this.reveal(item, child)) + grouplikeListing.on('timestamp', (item, time) => this.playOrSeek(item, time)) + grouplikeListing.pathElement.on('select', (item, child) => this.revealInLibrary(item, child)) grouplikeListing.on('menu', (item, el) => this.showMenuForItemElement(el, grouplikeListing)) /* grouplikeListing.on('select', item => this.editNotesFile(item, false)) grouplikeListing.on('edit-notes', item => { - this.reveal(item) + this.revealInLibrary(item) this.editNotesFile(item, true) }) */ @@ -832,7 +859,12 @@ class AppElement extends FocusElement { return menu } - reveal(item, child) { + browse(grouplikeListing, grouplike, ...args) { + this.loadTimestampDataInGrouplike(grouplike) + grouplikeListing.loadGrouplike(grouplike, ...args) + } + + revealInLibrary(item, child) { if (!this.tabberPane.visible) { return } @@ -854,6 +886,13 @@ class AppElement extends FocusElement { } } + revealInQueue(item) { + const queueListing = this.queueListingElement + if (queueListing.selectAndShow(item)) { + this.root.select(queueListing) + } + } + play(item) { if (!this.config.canControlQueue) { return @@ -862,6 +901,14 @@ class AppElement extends FocusElement { this.SQP.play(item) } + playOrSeek(item, time) { + if (!this.config.canControlQueue || !this.config.canControlPlayback) { + return + } + + this.SQP.playOrSeek(item, time) + } + unqueue(item) { if (!this.config.canControlQueue) { return @@ -1094,6 +1141,138 @@ class AppElement extends FocusElement { } */ + expandTimestamps(item, listing) { + listing.expandTimestamps(item) + } + + collapseTimestamps(item, listing) { + listing.collapseTimestamps(item) + } + + toggleTimestamps(item, listing) { + listing.toggleTimestamps(item) + } + + timestampsExpanded(item, listing) { + return listing.timestampsExpanded(item) + } + + hasTimestampsFile(item) { + return !!this.getTimestampsFile(item) + } + + getTimestampsFile(item) { + // Only tracks have timestamp files! + if (!isTrack(item)) { + return false + } + + return getCorrespondingFileForItem(item, '.timestamps.txt') + } + + async loadTimestampDataInGrouplike(grouplike) { + // Only load data for a grouplike once. + if (this.timestampDictionary.has(grouplike)) { + return + } + + this.timestampDictionary.set(grouplike, true) + + // There's no parallelization here, but like, whateeeever. + for (const item of grouplike.items) { + if (!isTrack(item)) { + continue + } + + if (this.timestampDictionary.has(item)) { + continue + } + + if (!this.hasTimestampsFile(item)) { + this.timestampDictionary.set(item, false) + continue + } + + this.timestampDictionary.set(item, null) + const data = await this.readTimestampData(item) + this.timestampDictionary.set(item, data) + } + } + + getTimestampData(item) { + return this.timestampDictionary.get(item) || null + } + + getTimestampAtSec(item, sec) { + const timestampData = this.getTimestampData(item) + if (!timestampData) { + return null + } + + // Just like, start from the end, man. + // Why doesn't JavaScript have a findIndexFromEnd function??? + for (let i = timestampData.length - 1; i >= 0; i--) { + const ts = timestampData[i]; + if ( + ts.timestamp <= sec && + ts.timestampEnd >= sec + ) { + return ts + } + } + + return null + } + + async readTimestampData(item) { + const file = this.getTimestampsFile(item) + + if (!file) { + return null + } + + let filePath + try { + filePath = url.fileURLToPath(new URL(file.url)) + } catch (error) { + return null + } + + let contents + try { + contents = (await readFile(filePath)).toString() + } catch (error) { + return null + } + + if (contents.startsWith('[')) { + try { + return JSON.parse(contents) + } catch (error) { + return null + } + } + + const lines = contents.split('\n') + .filter(line => !line.startsWith('#')) + .filter(line => line) + + const metadata = this.backend.getMetadataFor(item) + const duration = (metadata ? metadata.duration : Infinity) + + const data = lines + .map(line => line.match(/^\s*([0-9:.]+)\s*(\S.*)\s*$/)) + .filter(match => match) + .map(match => ({timestamp: getSecFromTimestamp(match[1]), comment: match[2]})) + .filter(({ timestamp: sec }) => !isNaN(sec)) + .map((cur, i, arr) => + (i + 1 === arr.length + ? {...cur, timestampEnd: duration} + : {...cur, timestampEnd: arr[i + 1].timestamp})) + + return data + } + openSpecialOrThroughSystem(item) { if (item.url.endsWith('.json')) { return this.loadPlaylistOrSource(item.url, true) @@ -1138,21 +1317,119 @@ class AppElement extends FocusElement { } } + skipBackOrSeekToStart() { + // Perform the same action - skipping to the previous track or seeking to + // the start of the current track - for all target queue players. If any is + // past an arbitrary time position (default 3 seconds), seek to start; if + // all are before this position, skip to previous. + + let maxCurSec = 0 + this.forEachQueuePlayerToActOn(qp => { + if (qp.timeData) { + let effectiveCurSec = qp.timeData.curSecTotal + + const ts = (qp.timeData && + this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal)) + + if (ts) { + effectiveCurSec -= ts.timestamp + } + + maxCurSec = Math.max(maxCurSec, effectiveCurSec) + } + }) + + if (Math.floor(maxCurSec) < this.config.seekToStartThreshold) { + this.skipBack() + } else { + this.seekToStart() + } + } + + seekToStart() { + this.actOnQueuePlayers(qp => qp.seekToStart()) + this.actOnQueuePlayers(qp => { + if (!qp.playingTrack) { + return + } + + const ts = (qp.timeData && + this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal)) + + if (ts) { + qp.seekTo(ts.timestamp) + return + } + + qp.seekToStart() + }) + } + + skipBack() { + this.actOnQueuePlayers(qp => { + if (!qp.playingTrack) { + return + } + + const ts = (qp.timeData && + this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal)) + + if (ts) { + const timestampData = this.getTimestampData(qp.playingTrack) + const playingTimestampIndex = timestampData.indexOf(ts) + const previous = timestampData[playingTimestampIndex - 1] + if (previous) { + qp.seekTo(previous.timestamp) + return + } + } + + qp.playPrevious(qp.playingTrack, true) + }) + } + + skipAhead() { + this.actOnQueuePlayers(qp => { + if (!qp.playingTrack) { + return + } + + const ts = (qp.timeData && + this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal)) + + if (ts) { + const timestampData = this.getTimestampData(qp.playingTrack) + const playingTimestampIndex = timestampData.indexOf(ts) + const next = timestampData[playingTimestampIndex + 1] + if (next) { + qp.seekTo(next.timestamp) + return + } + } + + qp.playNext(qp.playingTrack, true) + }) + } + actOnQueuePlayers(fn) { - const actOn = this.queuePlayersToActOn.length ? this.queuePlayersToActOn : [this.SQP] - for (const queuePlayer of actOn) { + this.forEachQueuePlayerToActOn(queuePlayer => { fn(queuePlayer) const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer) if (PIE) { PIE.updateProgress() } - } + }) + } + + forEachQueuePlayerToActOn(fn) { + const actOn = this.queuePlayersToActOn.length ? this.queuePlayersToActOn : [this.SQP] + actOn.forEach(fn) } showMenuForItemElement(el, listing) { - const { editMode } = this + // const { editMode } = this const { canControlQueue, canProcessMetadata } = this.config - const anyMarked = editMode && this.markGrouplike.items.length > 0 + // const anyMarked = editMode && this.markGrouplike.items.length > 0 const generatePageForItem = item => { const emitControls = play => () => { @@ -1163,22 +1440,43 @@ class AppElement extends FocusElement { }) } - const rootGroup = getItemPath(item)[0] + const itemPath = getItemPath(item) + const [rootGroup, _partySources, sharedGroup] = itemPath // This is the hack mentioned in the todo!!!! - if ( - this.config.showPartyControls && - rootGroup.isPartySources && - item[originalSymbol] - ) { - item = item[originalSymbol] + if (this.config.showPartyControls && rootGroup.isPartySources) { + const playlists = this.tabber.tabberElements + .map(grouplikeListing => getItemPath(grouplikeListing.grouplike)[0]) + .filter(root => !root.isPartySources) + + let possibleChoices = [] + if (item.downloaderArg) { + possibleChoices = getFlatTrackList({items: playlists}) + } else if (item.items) { + possibleChoices = getFlatGroupList({items: playlists}) + } + if (possibleChoices) { + item = findItemObject(item, possibleChoices) + } + + if (!item) { + return [ + {label: `(Couldn't find this in your music)`} + ] + } } - const hasNotesFile = !!getCorrespondingFileForItem(item, '.txt') + // const hasNotesFile = !!getCorrespondingFileForItem(item, '.txt') + const timestampsItem = this.hasTimestampsFile(item) && (this.timestampsExpanded(item, listing) + ? {label: 'Collapse saved timestamps', action: () => this.collapseTimestamps(item, listing)} + : {label: 'Expand saved timestamps', action: () => this.expandTimestamps(item, listing)} + ) + const isQueued = this.SQP.queueGrouplike.items.includes(item) if (listing.grouplike.isTheQueue && isTrack(item)) { return [ - item[parentSymbol] && this.tabberPane.visible && {label: 'Reveal', action: () => this.reveal(item)}, + item[parentSymbol] && this.tabberPane.visible && {label: 'Reveal in library', action: () => this.revealInLibrary(item)}, + timestampsItem, {divider: true}, canControlQueue && {label: 'Play later', action: () => this.playLater(item)}, canControlQueue && {label: 'Play sooner', action: () => this.playSooner(item)}, @@ -1212,13 +1510,12 @@ class AppElement extends FocusElement { // to move the "mark"/"paste" (etc) code into separate functions, // instead of just defining their behavior inside the listing event // handlers. - /* - editMode && {label: isMarked ? 'Unmark' : 'Mark', action: () => el.emit('mark')}, - anyMarked && {label: 'Paste (above)', action: () => el.emit('paste', {where: 'above'})}, - anyMarked && {label: 'Paste (below)', action: () => el.emit('paste', {where: 'below'})}, + + // editMode && {label: isMarked ? 'Unmark' : 'Mark', action: () => el.emit('mark')}, + // anyMarked && {label: 'Paste (above)', action: () => el.emit('paste', {where: 'above'})}, + // anyMarked && {label: 'Paste (below)', action: () => el.emit('paste', {where: 'below'})}, // anyMarked && !this.isReal && {label: 'Paste', action: () => this.emit('paste')}, // No "above" or "elow" in the label because the "fake" item/row will be replaced (it'll disappear, since there'll be an item in the group) - {divider: true}, - */ + // {divider: true}, ...((this.config.showPartyControls && !rootGroup.isPartySources) ? [ @@ -1231,25 +1528,24 @@ class AppElement extends FocusElement { canControlQueue && isPlayable(item) && {label: 'Play!', action: emitControls(true)}, canControlQueue && isPlayable(item) && {label: 'Queue!', action: emitControls(false)}, {divider: true}, - canProcessMetadata && isGroup(item) && {label: 'Process metadata (new entries)', action: () => setTimeout(() => this.processMetadata(item, false))}, canProcessMetadata && isGroup(item) && {label: 'Process metadata (reprocess)', action: () => setTimeout(() => this.processMetadata(item, true))}, canProcessMetadata && isTrack(item) && {label: 'Process metadata', action: () => setTimeout(() => this.processMetadata(item, true))}, isOpenable(item) && item.url.endsWith('.json') && {label: 'Open (JSON Playlist)', action: () => this.openSpecialOrThroughSystem(item)}, isOpenable(item) && {label: 'Open (System)', action: () => this.openThroughSystem(item)}, - /* - !hasNotesFile && isPlayable(item) && {label: 'Create notes file', action: () => this.editNotesFile(item, true)}, - hasNotesFile && isPlayable(item) && {label: 'Edit notes file', action: () => this.editNotesFile(item, true)}, - */ + // !hasNotesFile && isPlayable(item) && {label: 'Create notes file', action: () => this.editNotesFile(item, true)}, + // hasNotesFile && isPlayable(item) && {label: 'Edit notes file', action: () => this.editNotesFile(item, true)}, canControlQueue && isPlayable(item) && {label: 'Remove from queue', action: () => this.unqueue(item)}, + isTrack(item) && isQueued && {label: 'Reveal in queue', action: () => this.revealInQueue(item)}, {divider: true}, - ]), - ...(item === this.markGrouplike - ? [{label: 'Deselect all', action: () => this.unmarkAll()}] - : [ - this.getMarkStatus(item) !== 'unmarked' && {label: 'Remove from selection', action: () => this.unmarkItem(item)}, - this.getMarkStatus(item) !== 'marked' && {label: 'Add to selection', action: () => this.markItem(item)} + timestampsItem, + ...(item === this.markGrouplike + ? [{label: 'Deselect all', action: () => this.unmarkAll()}] + : [ + this.getMarkStatus(item) !== 'unmarked' && {label: 'Remove from selection', action: () => this.unmarkItem(item)}, + this.getMarkStatus(item) !== 'marked' && {label: 'Add to selection', action: () => this.markItem(item)} + ]) ]) ] } @@ -1261,7 +1557,7 @@ class AppElement extends FocusElement { ].filter(Boolean) // TODO: Implement this! :P - const isMarked = false + // const isMarked = false this.showContextMenu({ x: el.absLeft, @@ -1538,9 +1834,9 @@ class AppElement extends FocusElement { } else if (input.isStop(keyBuf)) { this.actOnQueuePlayers(qp => qp.stopPlaying()) } else if (input.isSkipBack(keyBuf)) { - this.actOnQueuePlayers(qp => qp.playPrevious(qp.playingTrack, true)) + this.skipBackOrSeekToStart() } else if (input.isSkipAhead(keyBuf)) { - this.actOnQueuePlayers(qp => qp.playNext(qp.playingTrack, true)) + this.skipAhead() } } @@ -1658,30 +1954,45 @@ class AppElement extends FocusElement { const oldName = item.name if (isGroup(item)) { - if (order === 'shuffle') { - item = { - name: `${oldName} (shuffled)`, - items: shuffleArray(flattenGrouplike(item).items) - } - } else if (order === 'shuffle-groups') { - item = shuffleOrderOfGroups(item) - item.name = `${oldName} (group order shuffled)` - } else if (order === 'reverse') { - item = { - name: `${oldName} (reversed)`, - items: flattenGrouplike(item).items.reverse() - } - } else if (order === 'reverse-groups') { - item = reverseOrderOfGroups(item) - item.name = `${oldName} (group order reversed)` - } else if (order === 'alphabetic') { - item = { - name: `${oldName} (alphabetic)`, - items: orderBy( - flattenGrouplike(item).items, - t => getNameWithoutTrackNumber(t).replace(/[^a-zA-Z0-9]/g, '') - ) - } + switch (order) { + case 'shuffle': + item = { + name: `${oldName} (shuffled)`, + items: shuffleArray(flattenGrouplike(item).items) + } + break + case 'shuffle-groups': + item = shuffleOrderOfGroups(item) + item.name = `${oldName} (group order shuffled)` + break + case 'reverse': + item = { + name: `${oldName} (reversed)`, + items: flattenGrouplike(item).items.reverse() + } + break + case 'reverse-groups': + item = reverseOrderOfGroups(item) + item.name = `${oldName} (group order reversed)` + break + case 'alphabetic': + item = { + name: `${oldName} (alphabetic)`, + items: orderBy( + flattenGrouplike(item).items, + t => getNameWithoutTrackNumber(t).replace(/[^a-zA-Z0-9]/g, '') + ) + } + break + case 'alphabetic-groups': + item = { + name: `${oldName} (group order alphabetic)`, + items: orderBy( + collapseGrouplike(item).items, + t => t.name.replace(/[^a-zA-Z0-9]/g, '') + ) + } + break } } else { // Make it into a grouplike that just contains itself. @@ -1759,9 +2070,14 @@ class AppElement extends FocusElement { return } - const { playingTrack, timeData } = this.SQP + const { playingTrack, timeData, queueEndMode } = this.SQP const { items } = this.SQP.queueGrouplike - const { currentItem: selectedTrack } = this.queueListingElement + const { + currentInput: currentInput, + currentItem: selectedTrack + } = this.queueListingElement + + const isTimestamp = (currentInput instanceof TimestampGrouplikeItemElement) let trackRemainSec = 0 let trackPassedSec = 0 @@ -1774,6 +2090,7 @@ class AppElement extends FocusElement { const playingIndex = items.indexOf(playingTrack) const selectedIndex = items.indexOf(selectedTrack) + const timestampData = playingTrack && this.getTimestampData(playingTrack) // This will be set to a list of tracks, which will later be used to // calculate a particular duration (as described below) to be shown in @@ -1796,7 +2113,10 @@ class AppElement extends FocusElement { durationRange = items durationAdd = 0 durationSymbol = '' - } else if (selectedIndex === playingIndex) { + } else if ( + selectedIndex === playingIndex && + (!isTimestamp || currentInput.isCurrentTimestamp) + ) { // Remaining length of the queue. if (timeData) { durationRange = items.slice(playingIndex + 1) @@ -1806,20 +2126,40 @@ class AppElement extends FocusElement { durationAdd = 0 } durationSymbol = '' - } else if (selectedIndex < playingIndex) { + } else if ( + selectedIndex < playingIndex || + (isTimestamp && currentInput.data.timestamp <= trackPassedSec) + ) { // Time since the selected track ended. durationRange = items.slice(selectedIndex + 1, playingIndex) durationAdd = trackPassedSec // defaults to 0: no need to check timeData durationSymbol = '-' - } else if (selectedIndex > playingIndex) { + if (isTimestamp) { + if (selectedIndex < playingIndex) { + durationRange.unshift(items[selectedIndex]) + } + durationAdd -= currentInput.data.timestampEnd + } + } else if ( + selectedIndex > playingIndex || + (isTimestamp && currentInput.data.timestamp > trackPassedSec) + ) { // Time until the selected track begins. if (timeData) { - durationRange = items.slice(playingIndex + 1, selectedIndex) - durationAdd = trackRemainSec + if (selectedIndex === playingIndex) { + durationRange = [] + durationAdd = -trackPassedSec + } else { + durationRange = items.slice(playingIndex + 1, selectedIndex) + durationAdd = trackRemainSec + } } else { durationRange = items.slice(playingIndex, selectedIndex) durationAdd = 0 } + if (isTimestamp) { + durationAdd += currentInput.data.timestamp + } durationSymbol = '+' } @@ -1830,28 +2170,117 @@ class AppElement extends FocusElement { const { duration: durationString } = getTimeStringsFromSec(0, durationTotal) this.queueTimeLabel.text = `(${durationSymbol + durationString + approxSymbol})` - let collapseExtraInfo = false if (playingTrack) { - let insertString - const distance = Math.abs(selectedIndex - playingIndex) - if (selectedIndex < playingIndex) { - insertString = ` (-${distance})` - collapseExtraInfo = true - } else if (selectedIndex > playingIndex) { - insertString = ` (+${distance})` - collapseExtraInfo = true - } else { - insertString = '' + let trackPart + let trackPartShort + let trackPartReallyShort + + { + const distance = Math.abs(selectedIndex - playingIndex) + + let insertString + let insertStringShort + if (selectedIndex < playingIndex) { + insertString = ` (-${distance})` + insertStringShort = `-${distance}` + } else if (selectedIndex > playingIndex) { + insertString = ` (+${distance})` + insertStringShort = `+${distance}` + } else { + insertString = '' + insertStringShort = '' + } + + trackPart = `${playingIndex + 1 + insertString} / ${items.length}` + trackPartShort = (insertString + ? `${playingIndex + 1 + insertStringShort}/${items.length}` + : `${playingIndex + 1}/${items.length}`) + trackPartReallyShort = (insertString + ? insertStringShort + : `#${playingIndex + 1}`) + } + + let timestampPart + + if (isTimestamp && selectedIndex === playingIndex) { + const selectedTimestampIndex = timestampData.indexOf(currentInput.data) + + const found = timestampData.findIndex(ts => ts.timestamp > trackPassedSec) + const playingTimestampIndex = (found >= 0 ? found - 1 : 0) + const distance = Math.abs(selectedTimestampIndex - playingTimestampIndex) + + let insertString + if (selectedTimestampIndex < playingTimestampIndex) { + insertString = ` (-${distance})` + } else if (selectedTimestampIndex > playingTimestampIndex) { + insertString = ` (+${distance})` + } else { + insertString = '' + } + + timestampPart = `${playingTimestampIndex + 1 + insertString} / ${timestampData.length}` + } + + let queueLoopPart + let queueLoopPartShort + + if (selectedIndex === playingIndex) { + switch (queueEndMode) { + case 'loop': + queueLoopPart = 'Repeat' + queueLoopPartShort = 'R' + break + case 'shuffle': + queueLoopPart = 'Shuffle' + queueLoopPartShort = 'S' + break + case 'end': + default: + break + } } - this.queueLengthLabel.text = `(${this.SQP.playSymbol} ${playingIndex + 1 + insertString} / ${items.length})` + + let partsTogether + + const all = () => `(${this.SQP.playSymbol} ${partsTogether})` + const tooWide = () => all().length > this.queuePane.contentW + + // goto irl + determineParts: { + if (timestampPart) { + if (queueLoopPart) { + partsTogether = `${trackPart} : ${timestampPart} »${queueLoopPartShort}` + } else { + partsTogether = `(${this.SQP.playSymbol} ${trackPart} : ${timestampPart})` + } + break determineParts + } + + if (queueLoopPart) includeQueueLoop: { + partsTogether = `${trackPart} » ${queueLoopPart}` + if (tooWide()) { + partsTogether = `${trackPart} »${queueLoopPartShort}` + if (tooWide()) { + break includeQueueLoop + } + } + break determineParts + } + + partsTogether = trackPart + if (tooWide()) { + partsTogether = trackPartShort + if (tooWide()) { + partsTogether = trackPartReallyShort + } + } + } + + this.queueLengthLabel.text = all() } else { this.queueLengthLabel.text = `(${items.length})` } - if (this.SQP.loopQueueAtEnd) { - this.queueLengthLabel.text += (collapseExtraInfo ? ` [L${unic.ELLIPSIS}]` : ` [Looping]`) - } - // Layout stuff to position the length and time labels correctly. this.queueLengthLabel.centerInParent() this.queueTimeLabel.centerInParent() @@ -1859,13 +2288,65 @@ class AppElement extends FocusElement { this.queueTimeLabel.y = this.queuePane.contentH - 1 } + updateQueueSelection(timeData, oldTimeData) { + if (!timeData) { + return + } + + const { playingTrack } = this.SQP + const { form } = this.queueListingElement + const { currentInput } = form + + if (!currentInput || currentInput.item !== playingTrack) { + return + } + + const timestamps = this.getTimestampData(playingTrack) + + if (!timestamps) { + return + } + + const tsOld = oldTimeData && + this.getTimestampAtSec(playingTrack, oldTimeData.curSecTotal) + const tsNew = + this.getTimestampAtSec(playingTrack, timeData.curSecTotal) + + if ( + tsNew !== tsOld && + currentInput instanceof TimestampGrouplikeItemElement && + currentInput.data === tsOld + ) { + const index = form.inputs.findIndex(el => ( + el.item === playingTrack && + el instanceof TimestampGrouplikeItemElement && + el.data === tsNew + )) + + if (index === -1) { + return + } + + form.curIndex = index + if (form.isSelected) { + form.updateSelectedElement() + } + form.scrollSelectedElementIntoView() + } + } + + setThemeColor(color) { + this.themeColor = color + this.menubar.color = color + } + get SQP() { // Just a convenient shorthand. return this.selectedQueuePlayer } get selectedQueuePlayer() { return this.getDep('selectedQueuePlayer') } - set selectedQueuePlayer(v) { return this.setDep('selectedQueuePlayer', v) } + set selectedQueuePlayer(v) { this.setDep('selectedQueuePlayer', v) } } class GrouplikeListingElement extends Form { @@ -1930,6 +2411,7 @@ class GrouplikeListingElement extends Form { this.grouplikeData = new WeakMap() this.autoscrollOffset = null + this.expandedTimestamps = [] } getNewForm() { @@ -2018,7 +2500,8 @@ class GrouplikeListingElement extends Form { if (isGroup(this.grouplike)) { this.grouplikeData.set(this.grouplike, { scrollItems: this.form.scrollItems, - currentItem: this.currentItem + currentItem: this.currentItem, + expandedTimestamps: this.expandedTimestamps }) } } @@ -2029,6 +2512,8 @@ class GrouplikeListingElement extends Form { this.form.scrollItems = data.scrollItems this.form.selectAndShow(data.currentItem) this.form.fixLayout() + this.expandedTimestamps = data.expandedTimestamps + this.buildTimestampItems() } } @@ -2104,6 +2589,186 @@ class GrouplikeListingElement extends Form { } } + expandTimestamps(item) { + if (this.grouplike && this.grouplike.items.includes(item)) { + const ET = this.expandedTimestamps + if (!ET.includes(item)) { + this.expandedTimestamps.push(item) + this.buildTimestampItems() + + if (this.currentItem === item) { + if (this.isSelected) { + this.form.selectInput(this.form.inputs[this.form.curIndex + 1]) + } else { + this.form.curIndex += 1 + } + } + } + } + } + + collapseTimestamps(item) { + const ET = this.expandedTimestamps // :alien: + if (ET.includes(item)) { + const restore = (this.currentItem === item) + + ET.splice(ET.indexOf(item), 1) + this.buildTimestampItems() + + if (restore) { + const { form } = this + const index = form.inputs.findIndex(inp => inp.item === item) + form.curIndex = index + if (form.isSelected) { + form.updateSelectedElement() + } + form.scrollSelectedElementIntoView() + } + } + } + + toggleTimestamps(item) { + if (this.timestampsExpanded(item)) { + this.collapseTimestamps(item) + } else { + this.expandTimestamps(item) + } + } + + timestampsExpanded(item) { + this.updateTimestamps() + return this.expandedTimestamps.includes(item) + } + + selectTimestampAtSec(item, sec) { + this.expandTimestamps(item) + + const { form } = this + let index = form.inputs.findIndex(el => ( + el.item === item && + el instanceof TimestampGrouplikeItemElement && + el.data.timestamp >= sec + )) + + if (index === -1) { + index = form.inputs.findIndex(el => el.item === item) + if (index === -1) { + return + } + } + + form.curIndex = index + if (form.isSelected) { + form.updateSelectedElement() + } + form.scrollSelectedElementIntoView() + } + + updateTimestamps() { + const ET = this.expandedTimestamps + if (ET) { + this.expandedTimestamps = ET.filter(item => this.grouplike.items.includes(item)) + } + } + + restoreSelectedInput(restoreInput) { + const { form } = this + const { inputs, currentInput } = form + + if (currentInput === restoreInput) { + return + } + + let inputToSelect + + if (inputs.includes(restoreInput)) { + inputToSelect = restoreInput + } else if (restoreInput instanceof InteractiveGrouplikeItemElement) { + inputToSelect = inputs.find(input => + input.item === restoreInput.item && + input instanceof InteractiveGrouplikeItemElement + ) + } else if (restoreInput instanceof TimestampGrouplikeItemElement) { + inputToSelect = inputs.find(input => + input.data === restoreInput.data && + input instanceof TimestampGrouplikeItemElement + ) + } + + if (!inputToSelect) { + return + } + + form.curIndex = inputs.indexOf(inputToSelect) + if (form.isSelected) { + form.updateSelectedElement() + } + form.scrollSelectedElementIntoView() + } + + buildTimestampItems(restoreInput = this.currentInput) { + const form = this.form + + // Clear up any existing timestamp items, since we're about to generate new + // ones! + form.children = form.children.filter(child => !(child instanceof TimestampGrouplikeItemElement)) + form.inputs = form.inputs.filter(child => !(child instanceof TimestampGrouplikeItemElement)) + + this.updateTimestamps() + + if (!this.expandedTimestamps) { + // Well that's going to have obvious consequences. + return + } + + for (const item of this.expandedTimestamps) { + // Find the main item element. The items we're about to generate will be + // inserted after it. + const mainElementIndex = form.inputs.findIndex(el => ( + el instanceof InteractiveGrouplikeItemElement && + el.item === item + )) + + const timestampData = this.app.getTimestampData(item) + + // Oh no. + // TODO: This should probably error report lol. + if (!timestampData) { + continue + } + + // Generate some items! Just go over the data list and generate one for + // each timestamp. + const tsElements = timestampData.map(ts => { + const el = new TimestampGrouplikeItemElement(item, ts, timestampData, this.app) + el.on('pressed', () => this.emit('timestamp', item, ts.timestamp)) + if (this.grouplike.isTheQueue) { + el.hideMetadata = true + } + return el + }) + + // Stick 'em in. Form doesn't implement an "insert input" function because + // why would life be easy, so we'll mangle the inputs array ourselves. + + form.inputs.splice(mainElementIndex + 1, 0, ...tsElements) + + let previousIndex = mainElementIndex + for (const el of tsElements) { + // We do addChild rather than a simple splice because addChild does more + // stuff than just sticking it in the array (e.g. setting the child's + // .parent property). What if addInput gets updated to do more stuff in + // a similar fashion? Well, then we're scr*wed! :) + form.addChild(el, previousIndex + 1) + previousIndex++ + } + } + + this.restoreSelectedInput(restoreInput) + this.scheduleDrawWithoutPropertyChange() + this.fixAllLayout() + } + buildItems(resetIndex = false) { if (!this.grouplike) { throw new Error('Attempted to call buildItems before a grouplike was loaded') @@ -2111,6 +2776,7 @@ class GrouplikeListingElement extends Form { this.commentLabel.text = this.grouplike.comment || '' + const restoreInput = this.form.currentInput const wasSelected = this.isSelected const form = this.form @@ -2171,8 +2837,11 @@ class GrouplikeListingElement extends Form { } } + this.buildTimestampItems(restoreInput) + // Just to make the selected-track-info bar fill right away (if it wasn't // already filled by a previous this.curIndex set). + /* eslint-disable-next-line no-self-assign */ form.curIndex = form.curIndex this.fixAllLayout() @@ -2194,6 +2863,8 @@ class GrouplikeListingElement extends Form { itemElement.on(evtName, (...data) => this.emit(evtName, itemElement.item, ...data)) } + itemElement.on('toggle-timestamps', () => this.toggleTimestamps(itemElement.item)) + /* itemElement.on('unselected labels', () => { if (!this.expandLabels) { @@ -2302,9 +2973,13 @@ class GrouplikeListingElement extends Form { } get currentItem() { - const element = this.form.inputs[this.form.curIndex] || null + const element = this.currentInput return element && element.item } + + get currentInput() { + return this.form.currentInput + } } class GrouplikeListingForm extends ListScrollForm { @@ -2319,6 +2994,10 @@ class GrouplikeListingForm extends ListScrollForm { } keyPressed(keyBuf) { + if (this.inputs.length === 0) { + return + } + if (input.isSelectUp(keyBuf)) { this.selectUp() } else if (input.isSelectDown(keyBuf)) { @@ -2334,7 +3013,6 @@ class GrouplikeListingForm extends ListScrollForm { set curIndex(newIndex) { this.setDep('curIndex', newIndex) this.emit('select', this.inputs[this.curIndex]) - return newIndex } get curIndex() { @@ -2345,6 +3023,10 @@ class GrouplikeListingForm extends ListScrollForm { return Math.max(0, this.inputs.findIndex(el => el instanceof InteractiveGrouplikeItemElement)) } + get currentInput() { + return this.inputs[this.curIndex] + } + selectAndShow(item) { const index = this.inputs.findIndex(inp => inp.item === item) if (index >= 0) { @@ -2421,7 +3103,6 @@ class GrouplikeListingForm extends ListScrollForm { } dragLeftRange(item) { - const { items } = this.app.markGrouplike if (this.selectMode === 'select') { if (!this.oldMarkedItems.includes(item)) { this.app.unmarkItem(item) @@ -2643,13 +3324,27 @@ class InlineListPickerElement extends FocusElement { // next or previous. (That's the point, it's inline.) This element is mainly // useful in forms or ContextMenus. - constructor(labelText, options, showContextMenu = null) { + constructor(labelText, options, optsOrShowContextMenu = null) { super() + this.labelText = labelText this.options = options - this.showContextMenu = showContextMenu - this.curIndex = 0 + + if (typeof optsOrShowContextMenu === 'function') { + this.showContextMenu = optsOrShowContextMenu + } + + if (typeof optsOrShowContextMenu === 'object') { + const opts = optsOrShowContextMenu + this.showContextMenu = opts.showContextMenu + this.getValue = opts.getValue + this.setValue = opts.setValue + } + this.keyboardIdentifier = this.labelText + + this.curIndex = 0 + this.refreshValue() } fixLayout() { @@ -2700,17 +3395,7 @@ class InlineListPickerElement extends FocusElement { } else if (telc.isLeft(keyBuf)) { this.previousOption() } else if (input.isMenu(keyBuf) && this.showContextMenu) { - this.showContextMenu({ - x: this.absLeft + ansi.measureColumns(this.labelText) + 1, - y: this.absTop + 1, - items: this.options.map(({ value, label }, index) => ({ - label: label, - action: () => { - this.curIndex = index - }, - isDefault: index === this.curIndex - })) - }) + this.showMenu() } else { return true } @@ -2724,6 +3409,8 @@ class InlineListPickerElement extends FocusElement { } else { this.root.select(this) } + } else if (button === 'right') { + this.showMenu() } else if (button === 'scroll-up') { this.previousOption() } else if (button === 'scroll-down') { @@ -2734,11 +3421,40 @@ class InlineListPickerElement extends FocusElement { return false } + + showMenu() { + this.showContextMenu({ + x: this.absLeft + ansi.measureColumns(this.labelText) + 1, + y: this.absTop + 1, + items: this.options.map(({ label }, index) => ({ + label, + action: () => { + this.curIndex = index + }, + isDefault: index === this.curIndex + })) + }) + } + + refreshValue() { + if (this.getValue) { + const value = this.getValue() + const index = this.options.findIndex(opt => opt.value === value) + if (index >= 0) { + this.curIndex = index + } + } + } + nextOption() { this.curIndex++ if (this.curIndex === this.options.length) { this.curIndex = 0 } + + if (this.setValue) { + this.setValue(this.curValue) + } } previousOption() { @@ -2746,6 +3462,10 @@ class InlineListPickerElement extends FocusElement { if (this.curIndex < 0) { this.curIndex = this.options.length - 1 } + + if (this.setValue) { + this.setValue(this.curValue) + } } get curValue() { @@ -2753,7 +3473,7 @@ class InlineListPickerElement extends FocusElement { } get curIndex() { return this.getDep('curIndex') } - set curIndex(v) { return this.setDep('curIndex', v) } + set curIndex(v) { this.setDep('curIndex', v) } } // Quite hacky, but ATM I can't think of any way to neatly tie getDep/setDep @@ -2943,6 +3663,9 @@ class ToggleControl extends FocusElement { } + // Note: ToggleControl doesn't specify refreshValue because it doesn't have an + // internal state for the current value. It sets and draws based on the value + // getter provided externally. toggle() { this.setValue(!this.getValue()) } @@ -3117,6 +3840,8 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement { } else if (telc.isEnter(keyBuf)) { if (isGroup(this.item)) { this.emit('browse') + } else if (this.app.hasTimestampsFile(this.item)) { + this.emit('toggle-timestamps') } else if (isTrack(this.item)) { this.emit('queue', {where: 'next', play: true}) } else if (!this.isPlayable) { @@ -3187,15 +3912,16 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement { writeStatus(writable) { const markStatus = this.app.getMarkStatus(this.item) + const color = this.app.themeColor + 30 if (this.isGroup) { // The ANSI attributes here will apply to the rest of the line, too. // (We don't reset the active attributes until after drawing the rest of // the line.) if (markStatus === 'marked' || markStatus === 'partial') { - writable.write(ansi.setAttributes([ansi.C_BLUE + 10])) + writable.write(ansi.setAttributes([color + 10])) } else { - writable.write(ansi.setAttributes([ansi.C_BLUE, ansi.A_BRIGHT])) + writable.write(ansi.setAttributes([color, ansi.A_BRIGHT])) } } else if (this.isTrack) { if (markStatus === 'marked') { @@ -3229,9 +3955,11 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement { } else if (!this.isPlayable) { writable.write('F') } else if (record.downloading) { - writable.write(braille[Math.floor(Date.now() / 250) % 6]) + writable.write(brailleChar) } else if (this.app.SQP.playingTrack === this.item) { writable.write('\u25B6') + } else if (this.app.hasTimestampsFile(this.item)) { + writable.write(':') } else { writable.write(' ') } @@ -3252,6 +3980,102 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement { } } +class TimestampGrouplikeItemElement extends BasicGrouplikeItemElement { + constructor(item, timestampData, tsDataArray, app) { + super('') + + this.app = app + this.data = timestampData + this.tsData = tsDataArray + this.item = item + this.hideMetadata = false + } + + drawTo(writable) { + const { data, tsData } = this + + const metadata = this.app.backend.getMetadataFor(this.item) + const last = tsData[tsData.length - 1] + const duration = ((metadata && metadata.duration) + || last.timestampEnd !== Infinity && last.timestampEnd + || last.timestamp) + const strings = getTimeStringsFromSec(data.timestamp, duration) + + this.text = ( + /* + (trackDuration + ? `(${strings.timeDone} - ${strings.percentDone})` + : `(${strings.timeDone})`) + + */ + `(${strings.timeDone})` + + (data.comment + ? ` ${data.comment}` + : '') + ) + + if (!this.hideMetadata) { + const durationString = (data.timestampEnd === Infinity + ? 'to end' + : getTimeStringsFromSec(0, data.timestampEnd - data.timestamp).duration) + + // Try to line up so there's one column of negative padding - the duration + // of the timestamp(s) should start one column before the duration of the + // actual track. This makes for a nice nested look! + const rightPadding = ' '.repeat(duration > 3600 ? 4 : 2) + this.rightText = ` (${durationString})` + rightPadding + } + + super.drawTo(writable) + } + + writeStatus(writable) { + let parts = [] + + const color = ansi.setAttributes([ansi.A_BRIGHT, 30 + this.app.themeColor]) + const reset = ansi.setAttributes([ansi.C_RESET]) + + if (this.isCurrentTimestamp) { + parts = [ + color, + ' ', + // reset, + '\u25B6 ', + // color, + ' ' + ] + } else { + parts = [ + color, + ' ', + reset, + ':', + color, + ' ' + ] + } + + for (const part of parts) { + writable.write(part) + } + + this.drawX += 4 + } + + get isCurrentTimestamp() { + const { SQP } = this.app + return ( + SQP.playingTrack === this.item && + SQP.timeData && + SQP.timeData.curSecTotal >= this.data.timestamp && + SQP.timeData.curSecTotal < this.data.timestampEnd + ) + } + + getLeftPadding() { + return 4 + } +} + class ListingJumpElement extends Form { constructor() { super() @@ -3589,7 +4413,6 @@ class PlaybackInfoElement extends FocusElement { this.app.backend.queuePlayers.length > 1 && { label: 'Delete', action: () => { - const { parent } = this this.app.removeQueuePlayer(this.queuePlayer) } } @@ -3690,17 +4513,17 @@ class PlaybackInfoElement extends FocusElement { } get curSecTotal() { return this.getDep('curSecTotal') } - set curSecTotal(v) { return this.setDep('curSecTotal', v) } + set curSecTotal(v) { this.setDep('curSecTotal', v) } get lenSecTotal() { return this.getDep('lenSecTotal') } - set lenSecTotal(v) { return this.setDep('lenSecTotal', v) } + set lenSecTotal(v) { this.setDep('lenSecTotal', v) } get volume() { return this.getDep('volume') } - set volume(v) { return this.setDep('volume', v) } + set volume(v) { this.setDep('volume', v) } get isLooping() { return this.getDep('isLooping') } - set isLooping(v) { return this.setDep('isLooping', v) } + set isLooping(v) { this.setDep('isLooping', v) } get isPaused() { return this.getDep('isPaused') } - set isPaused(v) { return this.setDep('isPaused', v) } + set isPaused(v) { this.setDep('isPaused', v) } get currentTrack() { return this.getDep('currentTrack') } - set currentTrack(v) { return this.setDep('currentTrack', v) } + set currentTrack(v) { this.setDep('currentTrack', v) } } class OpenPlaylistDialog extends Dialog { @@ -4110,6 +4933,16 @@ class ContextMenu extends FocusElement { return } + // Call refreshValue() on any items before they're shown, for items that + // provide it. (This is handy when reusing the same input across a menu that + // might be shown under different contexts.) + for (const item of items) { + const el = item.element + if (!el) continue + if (!el.refreshValue) continue + el.refreshValue() + } + if (!this.root.selectedElement.directAncestors.includes(this)) { this.selectedBefore = this.root.selectedElement } @@ -4461,9 +5294,14 @@ class Menubar extends ListScrollForm { if (this.keyboardSelector.keyPressed(keyBuf)) { return false - } else if (telc.isCaselessLetter(keyBuf, 'c')) { + } else if (input.isNextThemeColor(keyBuf)) { // For fun :) - this.color = (this.color % 8) + 1 + this.color = (this.color === 8 ? 1 : this.color + 1) + this.emit('color', this.color) + return false + } else if (input.isPreviousThemeColor(keyBuf)) { + this.color = (this.color === 1 ? 8 : this.color - 1) + this.emit('color', this.color) return false } else if (telc.isCaselessLetter(keyBuf, 'a')) { this.attribute = (this.attribute % 3) + 1 @@ -4520,9 +5358,9 @@ class Menubar extends ListScrollForm { } get color() { return this.getDep('color') } - set color(v) { return this.setDep('color', v) } + set color(v) { this.setDep('color', v) } get attribute() { return this.getDep('attribute') } - set attribute(v) { return this.setDep('attribute', v) } + set attribute(v) { this.setDep('attribute', v) } } class PartyBanner extends DisplayElement { @@ -4687,7 +5525,7 @@ class Log extends ListScrollForm { this.addInput(logMessage) this.fixLayout() this.scrollToEnd() - this.emit('log-message', logMessage) + this.emit('log message', logMessage) return logMessage } } @@ -4752,5 +5590,3 @@ class LogMessageLabel extends WrapLabel { ].filter(x => x !== null) } } - -module.exports = AppElement 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 |