diff options
-rw-r--r-- | backend.js | 30 | ||||
-rw-r--r-- | client.js | 2 | ||||
-rw-r--r-- | guess.js | 41 | ||||
-rw-r--r-- | package-lock.json | 201 | ||||
-rw-r--r-- | players.js | 2 | ||||
-rw-r--r-- | playlist-utils.js | 80 | ||||
-rw-r--r-- | screenshot.png | bin | 56156 -> 78417 bytes | |||
-rw-r--r-- | todo.txt | 36 | ||||
-rw-r--r-- | ui.js | 457 |
9 files changed, 662 insertions, 187 deletions
diff --git a/backend.js b/backend.js index f2610d0..d222d10 100644 --- a/backend.js +++ b/backend.js @@ -69,13 +69,14 @@ class QueuePlayer extends EventEmitter { this.playingTrack = null this.queueGrouplike = {name: 'Queue', isTheQueue: true, items: []} this.pauseNextTrack = false - this.alwaysStartPaused = false - this.waitWhenDonePlaying = false - + this.loopQueueAtEnd = false this.playedTrackToEnd = false this.timeData = null this.time = null + this.alwaysStartPaused = false + this.waitWhenDonePlaying = false + this.getPlayer = getPlayer this.getRecordFor = getRecordFor } @@ -460,8 +461,14 @@ class QueuePlayer extends EventEmitter { if (playingThisTrack) { this.playedTrackToEnd = true this.emit('done playing', this.playingTrack) - if (!this.waitWhenDonePlaying && !this.playNext(item)) { - this.clearPlayingTrack() + if (!this.waitWhenDonePlaying) { + if (!this.playNext(item)) { + if (this.loopQueueAtEnd) { + this.playFirst() + } else { + this.clearPlayingTrack() + } + } } } } @@ -524,6 +531,15 @@ class QueuePlayer extends EventEmitter { return true } + playFirst() { + const queue = this.queueGrouplike + if (queue.items.length) { + this.play(queue.items[0]) + return true + } + return false + } + clearPlayingTrack() { if (this.playingTrack !== null) { const oldTrack = this.playingTrack @@ -609,6 +625,10 @@ class QueuePlayer extends EventEmitter { this.emit('set-pause-next-track', !!value) } + setLoopQueueAtEnd(value) { + this.loopQueueAtEnd = !!value + } + get remainingTracks() { const index = this.queueGrouplike.items.indexOf(this.playingTrack) const length = this.queueGrouplike.items.length diff --git a/client.js b/client.js index 645fe42..aa854ed 100644 --- a/client.js +++ b/client.js @@ -66,7 +66,7 @@ const setupClient = async ({backend, writable, interfacer, appConfig}) => { let grouplike = { name: 'My ~/Music Library', comment: ( - '(Add songs and folders to ~/Music to make them show up here,' + + '(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'] } diff --git a/guess.js b/guess.js index 0748ce8..db9f8e8 100644 --- a/guess.js +++ b/guess.js @@ -36,13 +36,15 @@ async function game() { process.exit(1) } + const QP = await backend.addQueuePlayer() + // TODO: nah - backend.setVolume(60) + QP.setVolume(60) process.stdin.setRawMode(true) process.stdin.on('data', async data => { if (data[0] === 0x03) { - await backend.stopPlaying() + await QP.stopPlaying() process.exit(0) } }) @@ -50,9 +52,6 @@ async function game() { const sourcePath = process.argv[2] || os.homedir() + '/Music' let grouplike = {source: ['crawl-local', sourcePath]} grouplike = await processSmartPlaylist(grouplike) - - // TODO: Actually let the user choose this..! - const group = grouplike.items.find(item => item.name === 'library') const allTracks = flattenGrouplike(grouplike).items const displayTrack = (track, shouldLimit) => { @@ -68,8 +67,10 @@ async function game() { while (allTracks.length) { const track = allTracks[Math.floor(Math.random() * allTracks.length)] - backend.play(track) - await untilEvent(backend, 'playing') + QP.setPause(false) + const promise = untilEvent(QP, 'playing') + QP.play(track) + await promise console.log('-- Listen! Then press space to pause and make a guess. --') let startTime = Date.now() @@ -94,42 +95,46 @@ async function game() { write(`\r\x1b[${4 + input.length}C`) } - const echoFn = () => { + const fmtTime = () => { let t = (playTime + Date.now() - startTime) / 1000 t = Math.floor(t * 10) / 10 if (t % 1 === 0) { t = t + '.0' } - write(resetLine + t + 's') + return t + 's' + } + + const echoFn = () => { + write(resetLine + fmtTime()) } while (true) { let echo - if (!backend.player.isPaused) { + if (!QP.player.isPaused) { echo = setInterval(echoFn, 50) } const key = await untilEvent(process.stdin, 'data') clearInterval(echo) - if (key[0] === 0x10 || (key[0] === 0x20 && !backend.player.isPaused)) { - if (backend.player.isPaused) { + if (key[0] === 0x10 || (key[0] === 0x20 && !QP.player.isPaused)) { + if (QP.player.isPaused) { startTime = Date.now() console.log(resetLine + dim + '<Unpaused.>') write(ansi.resetAttributes()) } else { + console.log(resetLine + dim + `<Paused @ ${fmtTime()}. Type the track's name below! ^P to resume.>`) playTime += Date.now() - startTime - console.log(resetLine + dim + '<Paused. Type the track\'s name below! ^P to resume.>') write(ansi.resetAttributes()) echoFn() displayInput() } - backend.togglePause() + QP.togglePause() /* - } else if (key[0] === 0x3f && (!key.length || !backend.player.isPaused)) { - backend.setPause(false) + } else if (key[0] === 0x3f && (!key.length || !QP.player.isPaused)) { + QP.setPause(false) gaveUp = true break */ - } else if (backend.player.isPaused) { + } else if (QP.player.isPaused) { if (telc.isBackspace(key)) { input = input.slice(0, -1) giveUpNext = false @@ -153,7 +158,7 @@ async function game() { } } else { if (giveUpNext) { - backend.setPause(false) + QP.setPause(false) gaveUp = true break } else { diff --git a/package-lock.json b/package-lock.json index ae9a42f..3d18627 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,8 +1,207 @@ { "name": "mtui", "version": "0.0.1", - "lockfileVersion": 1, + "lockfileVersion": 2, "requires": true, + "packages": { + "": { + "version": "0.0.1", + "license": "GPL-3.0", + "dependencies": { + "command-exists": "^1.2.9", + "expand-home-dir": "0.0.3", + "mkdirp": "^0.5.5", + "natural-orderby": "^2.0.3", + "node-fetch": "^2.6.0", + "open": "^7.0.4", + "sanitize-filename": "^1.6.3", + "shortid": "^2.2.15", + "tempy": "^0.2.1", + "tui-lib": "^0.3.1" + }, + "bin": { + "mtui": "index.js" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/command-exists": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", + "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==" + }, + "node_modules/crypto-random-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz", + "integrity": "sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=", + "engines": { + "node": ">=4" + } + }, + "node_modules/defaults": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", + "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", + "dependencies": { + "clone": "^1.0.2" + } + }, + "node_modules/expand-home-dir": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/expand-home-dir/-/expand-home-dir-0.0.3.tgz", + "integrity": "sha1-ct6KBIbMKKO71wRjU5iCW1tign0=" + }, + "node_modules/is-docker": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.0.0.tgz", + "integrity": "sha512-pJEdRugimx4fBMra5z2/5iRdZ63OhYV0vr0Dwm5+xtW4D1FvRkB8hamMIhnWfyJeDdyr/aa7BDyNbtG38VxgoQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + }, + "node_modules/mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "dependencies": { + "minimist": "^1.2.5" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/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/natural-orderby": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-2.0.3.tgz", + "integrity": "sha512-p7KTHxU0CUrcOXe62Zfrb5Z13nLvPhSWR/so3kFulUQU0sgUll2Z0LwpsLN351eOOD+hRGu/F1g+6xDfPeD++Q==", + "engines": { + "node": "*" + } + }, + "node_modules/node-fetch": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", + "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==", + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/open": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/open/-/open-7.0.4.tgz", + "integrity": "sha512-brSA+/yq+b08Hsr4c8fsEW2CRzk1BmfN3SAK/5VCHQ9bdoZJ4qa/+AfR0xHjlbbZUyPkUHs1b8x1RqdyZdkVqQ==", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sanitize-filename": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", + "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/shortid": { + "version": "2.2.15", + "resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.15.tgz", + "integrity": "sha512-5EaCy2mx2Jgc/Fdn9uuDuNIIfWBpzY4XIlhoqtXF6qsf+/+SGZ+FxDdX/ZsMZiWupIWNqAEmiNY4RC+LSmCeOw==", + "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=", + "engines": { + "node": ">=4" + } + }, + "node_modules/tempy": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.2.1.tgz", + "integrity": "sha512-LB83o9bfZGrntdqPuRdanIVCPReam9SOZKW0fOy5I9X3A854GGWi0tjCqoXEk84XIEYBc/x9Hq3EFop/H5wJaw==", + "dependencies": { + "temp-dir": "^1.0.0", + "unique-string": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha1-QFkjkJWS1W94pYGENLC3hInKXys=", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/tui-lib": { + "version": "0.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/unique-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz", + "integrity": "sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=", + "dependencies": { + "crypto-random-string": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz", + "integrity": "sha1-9F8VDExm7uloGGUFq5P8u4rWv2E=" + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", + "dependencies": { + "defaults": "^1.0.3" + } + } + }, "dependencies": { "clone": { "version": "1.0.4", diff --git a/players.js b/players.js index 2056ed7..dde1fbf 100644 --- a/players.js +++ b/players.js @@ -126,7 +126,7 @@ module.exports.MPVPlayer = class extends Player { if (parseInt(percent) < lastPercent) { // mpv forgets commands you sent it whenever it loops, so you // have to specify them every time it loops. We do that whenever the - // position in the song decreases, since that means it may have + // position in the track decreases, since that means it may have // looped. this.setLoop(this.isLooping) } diff --git a/playlist-utils.js b/playlist-utils.js index b813181..5fbfff8 100644 --- a/playlist-utils.js +++ b/playlist-utils.js @@ -535,36 +535,70 @@ function getTrackIndexInParent(track) { const nameWithoutTrackNumberSymbol = Symbol('Cached name without track number') function getNameWithoutTrackNumber(track) { - // Be lazy and reuse an old value if possible! Don't do this if the track's - // name has changed. - const [oldName, cachedValue] = track[nameWithoutTrackNumberSymbol] || [] - if (cachedValue && track.name === oldName) { - return cachedValue + // A "part" is a series of numeric digits, separated from other parts by + // whitespace and dashes, 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 }) => { + const match = name.match(/[^0-9\-\s]/) + if (match) { + name = name.slice(0, match.index) + } else if (name.includes(' ')) { + name = name.slice(0, name.lastIndexOf(' ')) + } else { + return 0 + } + name = name.replace(/[\-\s]+$/, '') + return name.split(/[\-\s]+/g).length + } + + const removeParts = (name, numParts) => { + const regex = new RegExp(`([0-9]+[\\-\\s]+){${numParts},${numParts}}`) + return track.name.replace(regex, '') } - // This is the expression that matches a track number at the start of - // a track's title. - const regex = /^[0-9\-\s]+/ + // Despite this function returning a single string for one track, that value + // depends on the names of all other tracks under the same parent. We still + // store individual track -> name data on the track object, but the parent + // gets an additional cache for the names of its children tracks as well as + // the number of "parts" (the value directly based upon those names, and + // useful in computing the name data for other children tracks). - // First we need to determine whether every track in the group even starts - // with a track number. const parent = track[parentSymbol] if (parent) { - const names = parent.items.filter(isTrack).map(t => t.name) - if (names.some(name => !regex.test(name))) { - // If any of the names don't match the track number regex, just return - // the track name unmodified. - return track.name + const [trackNames, cachedNumParts] = parent[nameWithoutTrackNumberSymbol] || [] + const tracks = parent.items.filter(isTrack) + if (trackNames && tracks.length === trackNames.length && tracks.every((t, i) => t.name === trackNames[i])) { + const [, oldName, oldNumParts, cachedValue] = track[nameWithoutTrackNumberSymbol] || [] + if (cachedValue && track.name === oldName && cachedNumParts === oldNumParts) { + return cachedValue + } else { + // Individual track cache outdated. + const value = removeParts(track.name, cachedNumParts) + track[nameWithoutTrackNumberSymbol] = [true, track.name, cachedNumParts, value] + return value + } + } else { + // Group (parent) cache outdated. + const numParts = Math.min(...tracks.map(getNumberOfParts)) + parent[nameWithoutTrackNumberSymbol] = [tracks.map(t => t.name), numParts] + // Parent changed so track cache changed is outdated too. + const value = removeParts(track.name, numParts) + track[nameWithoutTrackNumberSymbol] = [true, track.name, numParts, value] + return value + } + } else { + const [oldHadParent, oldName, , cachedValue] = track[nameWithoutTrackNumberSymbol] || [] + if (cachedValue && !oldHadParent && track.name === oldName) { + return cachedValue + } else { + // Track cache outdated. + const numParts = getNumberOfParts(track) + const value = removeParts(track.name, numParts) + track[nameWithoutTrackNumberSymbol] = [false, track.name, numParts, value] + return value } } - - // Now actually perform the replacement to get rid of the track number! - const value = track.name.replace(regex, '') - - // Cache the value, so we don't need to do this whole process again. - track[nameWithoutTrackNumberSymbol] = [track.name, value] - - return value } function isGroup(obj) { diff --git a/screenshot.png b/screenshot.png index e7abe90..81bab72 100644 --- a/screenshot.png +++ b/screenshot.png Binary files differdiff --git a/todo.txt b/todo.txt index c58c1cb..34784cc 100644 --- a/todo.txt +++ b/todo.txt @@ -326,6 +326,14 @@ TODO: Figure out duplicates in the selection system! Right now, it's possible but it doesn't seem worth it - better to keep them separate and let us explicitly decide when we do or don't want to consider duplicates.) + (Done! But sort of in the reverse direction: you can't select groups + themselves anymore; whether they display as selected is based upon if + all their child tracks are selected. Accordingly, groups can also show + as "partial" selections, if only some of the tracks are selected. + Might be worth revisiting, but I think what I've got implemented is + easier to wrap your head around than this stuff, however cool the ideas + here probably are.) + TODO: Default to 'after selected track' in context menu, and make pressing Q (the shorthand for queuing the selection) act as though that's the selected option, instead of queuing at the end of the queue (which is @@ -462,9 +470,11 @@ TODO: Only count *consistently* formatted text, across all tracks in a group, it is the only track in the group which is formatted '## # <text>'. It does follow the formatting '## <text>' as all other tracks do, so only the first digits, and following whitespace, should be removed. + (Done!) TODO: Related to the above - always keep at least one word. See CANSLP's new release "untitled folder", with tracks named "01 1", "02 2", etc. + (Done!) TODO: Update to work with IPC server mpv (and socat). (Done!) @@ -479,13 +489,17 @@ TODO: Expand selection context menu by pressing the heading button! It should TODO: Opening the selection contxt menu should show an option to either add or remove the cursor-focused item from the selection - this would make selection accessible when a keyboard or the shift key is inaccessible. + (Done!) TODO: Integrate the rest of the stuff that handles argv into parseOptions. + (Done!) TODO: Figure out looping not always working consistently. I've tried to deal with this before, but it's been broken since switching to socat. Maybe we aren't receiving time data as consistently, or aren't re-applying loop when we're supposed to? + (Update: I'm pretty sure this is from socat messages getting dropped - + probably by fault of mpv, not socat or mtui.) TODO: Show how many tracks remain in a queue player's queue, ala "+1" floated to the right, behind the playback position/duration indicator. @@ -524,8 +538,10 @@ TODO: In that regard, also change the queue length label (just above the time TODO: Deselecting a grouplike listing (e.g. by clicking elsewhere) should hide its "jump to" element. + (Done!) TODO: A "before selected item" option for in the queue menu! + (Done!) TODO: The sorting for library3/C418 seems to be weird???? Could be pointing to some bug! @@ -536,7 +552,27 @@ TODO: Selecting a group from the path listing at the bottom of listings should For example: selecting X in W/X/Y/Z would open the directory X and select item Y; selecting Z would open the directory Z and select the track (or group) which the path element is active on in the first place. + (Done!) TODO: UI to change the directory from which mtui reads music by default! TODO: file/folder browse-select UI 0_0 + +TODO: Change any "song" terminology to "track" in the UI. + (Done!) + +TODO: Empty groups show as selected, lol! + +TODO: Multipage context menu doesn't work well in the queue - fix this by + adding a multipage heading option (or whatever I called em lol) to the + queue context menu! + +TODO: Names like "10. Banana" don't get cropped! Dots/dashes *after* a number + apparently don't get caught. Oops. + +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. + +TODO: Pressing next track (shift+N) on the last track should start the first + track, if the queue is being looped. diff --git a/ui.js b/ui.js index 3bac8c6..68cda91 100644 --- a/ui.js +++ b/ui.js @@ -65,6 +65,7 @@ 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') @@ -135,6 +136,7 @@ const keyBindings = [ ['isTogglePause', '5'], ['isBackspace', '.'], ['isMenu', '+'], + ['isMenu', '0'], ['isSkipBack', '1'], ['isSkipAhead', '3'], // Disabled because this is the jump key! Oops. @@ -210,6 +212,7 @@ class AppElement extends FocusElement { // TODO: Move edit mode stuff to the backend! this.undoManager = new UndoManager() this.markGrouplike = {name: 'Selected Items', items: []} + this.cachedMarkStatuses = new Map() this.editMode = false // We add this is a child later (so that it's on top of every element). @@ -324,11 +327,12 @@ class AppElement extends FocusElement { this.addChild(this.menuLayer) this.whereControl = new InlineListPickerElement('Where?', [ - {value: 'next-selected', label: 'After selected song'}, - {value: 'next', label: 'After current song'}, + {value: 'after-selected', label: 'After selected track'}, + {value: 'next', label: 'After current track'}, {value: 'end', label: 'At end of queue'}, {value: 'distribute-evenly', label: 'Distributed across queue evenly'}, - {value: 'distribute-randomly', label: 'Distributed across queue randomly'} + {value: 'distribute-randomly', label: 'Distributed across queue randomly'}, + {value: 'before-selected', label: 'Before selected track'} ], this.showContextMenu) this.orderControl = new InlineListPickerElement('Order?', [ @@ -336,6 +340,7 @@ class AppElement extends FocusElement { {value: 'shuffle-groups', label: 'Shuffle order of groups'}, {value: 'reverse', label: 'Reverse all'}, {value: 'reverse-groups', label: 'Reverse order of groups'}, + {value: 'alphabetic', label: 'Alphabetically'}, {value: 'normal', label: 'In order'} ], this.showContextMenu) @@ -358,6 +363,7 @@ class AppElement extends FocusElement { {divider: true}, playingTrack && {element: this.playingControl}, {element: this.loopingControl}, + {element: this.loopQueueControl}, {element: this.pauseNextControl}, {element: this.autoDJControl}, {element: this.volumeSlider}, @@ -413,6 +419,12 @@ class AppElement extends FocusElement { getEnabled: () => this.config.canControlPlayback }) + this.loopQueueControl = new ToggleControl('Loop queue?', { + setValue: val => this.SQP.setLoopQueueAtEnd(val), + getValue: () => this.SQP.loopQueueAtEnd, + getEnabled: () => this.config.canControlPlayback + }) + this.volumeSlider = new SliderElement('Volume', { setValue: val => this.SQP.setVolume(val), getValue: () => this.SQP.player.volume, @@ -761,7 +773,7 @@ class AppElement extends FocusElement { // Sets up event listeners that are common to ordinary grouplike listings // (made by newGrouplikeListing) as well as the queue grouplike listing. - grouplikeListing.pathElement.on('select', item => this.reveal(item)) + grouplikeListing.pathElement.on('select', (item, child) => this.reveal(item, child)) grouplikeListing.on('menu', (item, el) => this.showMenuForItemElement(el, grouplikeListing)) /* grouplikeListing.on('select', item => this.editNotesFile(item, false)) @@ -782,7 +794,7 @@ class AppElement extends FocusElement { return menu } - reveal(item) { + reveal(item, child) { if (!this.tabberPane.visible) { return } @@ -793,6 +805,9 @@ class AppElement extends FocusElement { const parent = item[parentSymbol] if (isGroup(item)) { tabberListing.loadGrouplike(item) + if (child) { + tabberListing.selectAndShow(child) + } } else if (parent) { if (tabberListing.grouplike !== parent) { tabberListing.loadGrouplike(parent) @@ -866,8 +881,73 @@ class AppElement extends FocusElement { this.queueListingElement.selectAndShow(item) } - deselectAll() { - this.markGrouplike.items.splice(0) + replaceMark(items) { + this.markGrouplike.items = items.slice(0) // Don't share the array! :) + this.emitMarkChanged() + } + + unmarkAll() { + this.markGrouplike.items = [] + this.emitMarkChanged() + } + + markItem(item) { + if (isGroup(item)) { + for (const child of item.items) { + this.markItem(child) + } + } else { + const { items } = this.markGrouplike + if (!items.includes(item)) { + items.push(item) + this.emitMarkChanged() + } + } + } + + unmarkItem(item) { + if (isGroup(item)) { + for (const child of item.items) { + this.unmarkItem(child) + } + } else { + const { items } = this.markGrouplike + if (items.includes(item)) { + items.splice(items.indexOf(item), 1) + this.emitMarkChanged() + } + } + } + + getMarkStatus(item) { + if (!this.cachedMarkStatuses.get(item)) { + const { items } = this.markGrouplike + let status + if (isGroup(item)) { + const tracks = flattenGrouplike(item).items + if (tracks.every(track => items.includes(track))) { + status = 'marked' + } else if (tracks.some(track => items.includes(track))) { + status = 'partial' + } else { + status = 'unmarked' + } + } else { + if (items.includes(item)) { + status = 'marked' + } else { + status = 'unmarked' + } + } + this.cachedMarkStatuses.set(item, status) + } + return this.cachedMarkStatuses.get(item) + } + + emitMarkChanged() { + this.emit('mark changed') + this.cachedMarkStatuses = new Map() + this.scheduleDrawWithoutPropertyChange() } pauseAll() { @@ -1024,99 +1104,104 @@ class AppElement extends FocusElement { } showMenuForItemElement(el, listing) { - const emitControls = play => () => { - this.handleQueueOptions(item, { - where: this.whereControl.curValue, - order: this.orderControl.curValue, - play: play - }) - } - - let item - if (this.markGrouplike.items.length) { - item = this.markGrouplike - } else { - item = el.item - } - - // TODO: Implement this! :P - const isMarked = false - const { editMode } = this const { canControlQueue, canProcessMetadata } = this.config const anyMarked = editMode && this.markGrouplike.items.length > 0 - const hasNotesFile = !!getCorrespondingFileForItem(item, '.txt') - let items; - if (listing.grouplike.isTheQueue && isTrack(item)) { - items = [ - item[parentSymbol] && this.tabberPane.visible && {label: 'Reveal', action: () => this.reveal(item)}, - {divider: true}, - canControlQueue && {label: 'Play later', action: () => this.playLater(item)}, - canControlQueue && {label: 'Play sooner', action: () => this.playSooner(item)}, - {divider: true}, - canControlQueue && {label: 'Clear past this track', action: () => this.clearQueuePast(item)}, - canControlQueue && {label: 'Clear up to this track', action: () => this.clearQueueUpTo(item)}, - {divider: true}, - {label: 'Autoscroll', action: () => listing.toggleAutoscroll()}, - {divider: true}, - canControlQueue && {label: 'Remove from queue', action: () => this.unqueue(item)} - ] - } else { - const numTracks = countTotalTracks(item) - const { string: durationString } = this.backend.getDuration(item) - items = [ - // A label that just shows some brief information about the item. - {label: - `(${item.name ? `"${item.name}"` : 'Unnamed'} - ` + - (isGroup(item) ? ` ${numTracks} track${numTracks === 1 ? '' : 's'}, ` : '') + - durationString + - ')', - keyboardIdentifier: item.name - }, + const generatePageForItem = item => { + const emitControls = play => () => { + this.handleQueueOptions(item, { + where: this.whereControl.curValue, + order: this.orderControl.curValue, + play: play + }) + } - // The actual controls! - {divider: true}, + const hasNotesFile = !!getCorrespondingFileForItem(item, '.txt') + if (listing.grouplike.isTheQueue && isTrack(item)) { + return [ + item[parentSymbol] && this.tabberPane.visible && {label: 'Reveal', action: () => this.reveal(item)}, + {divider: true}, + canControlQueue && {label: 'Play later', action: () => this.playLater(item)}, + canControlQueue && {label: 'Play sooner', action: () => this.playSooner(item)}, + {divider: true}, + canControlQueue && {label: 'Clear past this track', action: () => this.clearQueuePast(item)}, + canControlQueue && {label: 'Clear up to this track', action: () => this.clearQueueUpTo(item)}, + {divider: true}, + {label: 'Autoscroll', action: () => listing.toggleAutoscroll()}, + {divider: true}, + canControlQueue && {label: 'Remove from queue', action: () => this.unqueue(item)} + ] + } else { + const numTracks = countTotalTracks(item) + const { string: durationString } = this.backend.getDuration(item) + return [ + // A label that just shows some brief information about the item. + {label: + `(${item.name ? `"${item.name}"` : 'Unnamed'} - ` + + (isGroup(item) ? ` ${numTracks} track${numTracks === 1 ? '' : 's'}, ` : '') + + durationString + + ')', + keyboardIdentifier: item.name, + isPageSwitcher: true + }, - // TODO: Don't emit these on the element (and hence receive them from - // the listing) - instead, handle their behavior directly. We'll want - // 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'})}, - // 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}, - */ + // The actual controls! + {divider: true}, - canControlQueue && isPlayable(item) && {element: this.whereControl}, - canControlQueue && isGroup(item) && {element: this.orderControl}, - canControlQueue && isPlayable(item) && {label: 'Play!', action: emitControls(true)}, - canControlQueue && isPlayable(item) && {label: 'Queue!', action: emitControls(false)}, - {divider: true}, + // TODO: Don't emit these on the element (and hence receive them from + // the listing) - instead, handle their behavior directly. We'll want + // 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'})}, + // 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}, + */ - 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)}, - */ - canControlQueue && isPlayable(item) && {label: 'Remove from queue', action: () => this.unqueue(item)}, - {divider: true}, + canControlQueue && isPlayable(item) && {element: this.whereControl}, + canControlQueue && isGroup(item) && {element: this.orderControl}, + canControlQueue && isPlayable(item) && {label: 'Play!', action: emitControls(true)}, + canControlQueue && isPlayable(item) && {label: 'Queue!', action: emitControls(false)}, + {divider: true}, - item === this.markGrouplike && {label: 'Deselect', action: () => this.deselectAll()} - ] + 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)}, + */ + canControlQueue && isPlayable(item) && {label: 'Remove from queue', action: () => this.unqueue(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)} + ]) + ] + } } + const pages = [ + this.markGrouplike.items.length && generatePageForItem(this.markGrouplike), + el.item && generatePageForItem(el.item) + ].filter(Boolean) + + // TODO: Implement this! :P + const isMarked = false + this.showContextMenu({ x: el.absLeft, y: el.absTop + 1, - items + pages }) } @@ -1524,26 +1609,45 @@ class AppElement extends FocusElement { } 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, '') + ) + } } } else { // Make it into a grouplike that just contains itself. item = {name: oldName, items: [item]} } - if (where === 'next' || where === 'next-selected' || where === 'end') { + if (where === 'next' || where === 'after-selected' || where === 'before-selected' || where === 'end') { + const selected = this.queueListingElement.currentItem let afterItem = null if (where === 'next') { afterItem = playingTrack - } else if (where === 'next-selected') { - afterItem = this.queueListingElement.currentItem + } else if (where === 'after-selected') { + afterItem = selected + } else if (where === 'before-selected') { + const { items } = this.SQP.queueGrouplike + const index = items.indexOf(selected) + if (index === 0) { + afterItem = 'FRONT' + } else if (index > 0) { + afterItem = items[index - 1] + } } this.SQP.queue(item, afterItem, { - movePlayingTrack: order === 'normal' + movePlayingTrack: order === 'normal' || order === 'alphabetic' }) if (isTrack(passedItem)) { this.queueListingElement.selectAndShow(passedItem) + } else { + this.queueListingElement.selectAndShow(selected) } } else if (where.startsWith('distribute-')) { this.SQP.distributeQueue(item, { @@ -1823,7 +1927,7 @@ class GrouplikeListingElement extends Form { */ } } else if (keyBuf[0] === 1) { // ctrl-A - this.toggleSelectAll() + this.toggleMarkAll() } else { return super.keyPressed(keyBuf) } @@ -1864,14 +1968,34 @@ class GrouplikeListingElement extends Form { this.form.scrollItems = 0 } - toggleSelectAll() { + toggleMarkAll() { const { items } = this.grouplike - if (items.every(item => this.app.markGrouplike.items.includes(item))) { - this.app.markGrouplike.items = [] + const actions = [] + const tracks = flattenGrouplike(this.grouplike).items + if (items.every(item => this.app.getMarkStatus(item) !== 'unmarked')) { + if (this.app.markGrouplike.items.length > tracks.length) { + actions.push({label: 'Remove from selection', action: () => this.app.unmarkItem(this.grouplike)}) + } + actions.push({label: 'Clear selection', action: () => this.app.unmarkAll()}) } else { - this.app.markGrouplike.items = items.slice(0) // Don't share the array! :) + actions.push({label: 'Add to selection', action: () => this.app.markItem(this.grouplike)}) + if (this.app.markGrouplike.items.some(item => !tracks.includes(item))) { + actions.push({label: 'Replace selection', action: () => { + this.app.unmarkAll() + this.app.markItem(this.grouplike) + }}) + } + } + if (actions.length === 1) { + actions[0].action() + } else { + const el = this.form.inputs[this.form.curIndex] + this.app.showContextMenu({ + x: el.absLeft, + y: el.absTop + 1, + items: actions + }) } - this.scheduleDrawWithoutPropertyChange() } /* @@ -2080,15 +2204,21 @@ class GrouplikeListingElement extends Form { } hideJumpElement(isCancel) { - if (isCancel) { - this.form.curIndex = this.oldFocusedIndex - this.form.scrollSelectedElementIntoView() - } - this.jumpElement.visible = false - if (this.jumpElement.isSelected) { - this.root.select(this) + if (this.jumpElement.visible) { + if (isCancel) { + this.form.curIndex = this.oldFocusedIndex + this.form.scrollSelectedElementIntoView() + } + this.jumpElement.visible = false + if (this.jumpElement.isSelected) { + this.root.select(this) + } + this.fixLayout() } - this.fixLayout() + } + + unselected() { + this.hideJumpElement(true) } get tabberLabel() { @@ -2168,13 +2298,13 @@ class GrouplikeListingForm extends ListScrollForm { return } const { item } = input - if (this.app.markGrouplike.items.includes(item)) { - this.selectMode = 'deselect' - } else { + if (this.app.getMarkStatus(item) === 'unmarked') { if (!ctrl) { - this.app.markGrouplike.items = [] + this.app.unmarkAll() } this.selectMode = 'select' + } else { + this.selectMode = 'deselect' } if (ctrl) { this.dragInputs = [item] @@ -2211,27 +2341,22 @@ class GrouplikeListingForm extends ListScrollForm { } dragEnteredRange(item) { - const { items } = this.app.markGrouplike if (this.selectMode === 'select') { - if (!items.includes(item)) { - items.push(item) - } + this.app.markItem(item) } else if (this.selectMode === 'deselect') { - if (items.includes(item)) { - items.splice(items.indexOf(item), 1) - } + this.app.unmarkItem(item) } } dragLeftRange(item) { const { items } = this.app.markGrouplike if (this.selectMode === 'select') { - if (items.includes(item) && !this.oldMarkedItems.includes(item)) { - items.splice(items.indexOf(item), 1) + if (!this.oldMarkedItems.includes(item)) { + this.app.unmarkItem(item) } } else if (this.selectMode === 'deselect') { - if (!items.includes(item) && this.oldMarkedItems.includes(item)) { - items.push(item) + if (this.oldMarkedItems.includes(item)) { + this.app.markItem(item) } } } @@ -2270,11 +2395,13 @@ class GrouplikeListingForm extends ListScrollForm { return } this.keyboardDragDirection = direction - this.oldMarkedItems = this.app.markGrouplike.items.slice() - if (this.app.markGrouplike.items.includes(item)) { - this.selectMode = 'deselect' - } else { + this.oldMarkedItems = (this.inputs + .filter(input => input.item && this.app.getMarkStatus(input.item) !== 'unmarked') + .map(input => input.item)) + if (this.app.getMarkStatus(item) === 'unmarked') { this.selectMode = 'select' + } else { + this.selectMode = 'deselect' } this.dragEnteredRange(item) } @@ -2987,21 +3114,23 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement { } writeStatus(writable) { + const markStatus = this.app.getMarkStatus(this.item) + 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 (this.isMarked) { + if (markStatus === 'marked' || markStatus === 'partial') { writable.write(ansi.setAttributes([ansi.C_BLUE + 10])) } else { writable.write(ansi.setAttributes([ansi.C_BLUE, ansi.A_BRIGHT])) } } else if (this.isTrack) { - if (this.isMarked) { + if (markStatus === 'marked') { writable.write(ansi.setAttributes([ansi.C_WHITE + 10, ansi.C_BLACK, ansi.A_BRIGHT])) } } else if (!this.isPlayable) { - if (this.isMarked) { + if (markStatus === 'marked') { writable.write(ansi.setAttributes([ansi.C_WHITE + 10, ansi.C_BLACK, ansi.A_BRIGHT])) } else { writable.write(ansi.setAttributes([ansi.A_DIM])) @@ -3015,8 +3144,10 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement { const record = this.app.backend.getRecordFor(this.item) - if (this.isMarked) { - writable.write('>') + if (markStatus === 'marked') { + writable.write('+') + } else if (markStatus === 'partial') { + writable.write('*') } else { writable.write(' ') } @@ -3036,10 +3167,6 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement { writable.write(' ') } - get isMarked() { - return this.app.markGrouplike.items.includes(this.item) - } - get isGroup() { return isGroup(this.item) } @@ -3113,10 +3240,12 @@ class PathElement extends ListScrollForm { const itemPath = getItemPath(item) const parentPath = itemPath.slice(0, -1) - for (const pathItem of parentPath) { - const isFirst = pathItem === parentPath[0] + for (let i = 0; i < parentPath.length; i++) { + const pathItem = parentPath[i] + const nextItem = itemPath[i + 1] + const isFirst = (i === 0) const element = new PathItemElement(pathItem, isFirst) - element.on('select', () => this.emit('select', pathItem)) + element.on('select', () => this.emit('select', pathItem, nextItem)) element.fixLayout() this.addInput(element) } @@ -3861,7 +3990,7 @@ class ContextMenu extends FocusElement { this.submenu = null } - show({x = 0, y = 0, items: itemsArg, focusKey = null}) { + show({x = 0, y = 0, pages = null, items: itemsArg = null, focusKey = null, pageNum = 0}) { this.reload = () => { const els = [this.root.selectedElement, ...this.root.selectedElement.directAncestors] const focusKey = Object.keys(keyElementMap).find(key => els.includes(keyElementMap[key])) @@ -3869,6 +3998,39 @@ class ContextMenu extends FocusElement { this.show({x, y, items: itemsArg, focusKey}) } + this.nextPage = () => { + if (pages.length > 1) { + pageNum++ + if (pageNum === pages.length) { + pageNum = 0 + } + this.close(false) + this.show({x, y, pages, pageNum}) + } + } + + this.previousPage = () => { + if (pages.length > 1) { + pageNum-- + if (pageNum === -1) { + pageNum = pages.length - 1 + } + this.close(false) + this.show({x, y, pages, pageNum}) + } + } + + if (!pages && !itemsArg || pages && itemsArg) { + return + } + + if (pages) { + if (pages.length === 0) { + return + } + itemsArg = pages[pageNum] + } + let items = (typeof itemsArg === 'function') ? itemsArg() : itemsArg items = items.filter(Boolean) @@ -3918,8 +4080,12 @@ class ContextMenu extends FocusElement { wantDivider = true } else { addDividerIfWanted() - const button = new Button(item.label) - button.keyboardIdentifier = item.keyboardIdentifier || item.label + let label = item.label + if (item.isPageSwitcher && pages.length > 1) { + label = `\x1b[2m(${pageNum + 1}/${pages.length}) « \x1b[22m${label}\x1b[2m »\x1b[22m` + } + const button = new Button(label) + button.keyboardIdentifier = item.keyboardIdentifier || label if (item.action) { button.on('pressed', async () => { this.restoreSelection() @@ -3930,6 +4096,12 @@ class ContextMenu extends FocusElement { } }) } + if (item.isPageSwitcher) { + button.on('pressed', async () => { + this.nextPage() + }) + } + button.item = item focusEl = button this.form.addInput(button) if (item.isDefault) { @@ -3980,6 +4152,15 @@ class ContextMenu extends FocusElement { this.form.scrollToBeginning() } else if (input.isScrollToEnd(keyBuf)) { this.form.lastInput() + } else if (input.isLeft(keyBuf) || input.isRight(keyBuf)) { + if (this.form.inputs[this.form.curIndex].item.isPageSwitcher) { + if (input.isLeft(keyBuf)) { + this.previousPage() + } else { + this.nextPage() + } + return false + } } else { return super.keyPressed(keyBuf) } |