From 0e0cb91ffc1e7a28a03428528d437e074145a72e Mon Sep 17 00:00:00 2001 From: Florrie Date: Tue, 7 Jul 2020 11:31:29 -0300 Subject: make the mtui menu work again! i forgot to implement menuItems. oops. :P --- ui.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui.js b/ui.js index 0bd512a..04c65c0 100644 --- a/ui.js +++ b/ui.js @@ -4175,7 +4175,7 @@ class Menubar extends ListScrollForm { button.on('pressed', () => { this.contextMenu = this.showContextMenu({ x: container.absLeft, y: container.absY + 1, - items: menuFn + items: menuFn || menuItems }) this.contextMenu.on('closed', () => { this.contextMenu = null -- cgit 1.3.0-6-gf8a5 From 8baf386abdf611acd855e61578f06cf7ac8f014f Mon Sep 17 00:00:00 2001 From: Florrie Date: Thu, 9 Jul 2020 14:40:25 -0300 Subject: rename handlePlaylistSource & let accept grouplike It's called loadPlaylistOrSource now, and can take a grouplike (which it will process with processSmartPlaylist as usual) instead of a URL to pass to a crawler. This is so that all functionality for loading a playlist can be collected in and accessed through one interface, so that modifications to the way playlists are loaded will be reflected across everywhere that loads a playlist. --- client.js | 3 +-- index.js | 2 +- ui.js | 34 +++++++++++++++++++--------------- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/client.js b/client.js index 33cc62a..645fe42 100644 --- a/client.js +++ b/client.js @@ -70,8 +70,7 @@ const setupClient = async ({backend, writable, interfacer, appConfig}) => { ' or pass mtui your own playlist.json file!)'), source: ['crawl-local', os.homedir() + '/Music'] } - grouplike = await processSmartPlaylist(grouplike) - appElement.tabber.currentElement.loadGrouplike(grouplike) + await appElement.loadPlaylistOrSource(grouplike, true) root.select(appElement) diff --git a/index.js b/index.js index c40bbb9..4b7654d 100755 --- a/index.js +++ b/index.js @@ -125,7 +125,7 @@ async function main() { const loadPlaylists = async () => { for (const source of playlistSources) { - await appElement.handlePlaylistSource(source, true) + await appElement.loadPlaylistOrSource(source, true) } } diff --git a/ui.js b/ui.js index 04c65c0..41ab4cc 100644 --- a/ui.js +++ b/ui.js @@ -295,8 +295,8 @@ class AppElement extends FocusElement { this.openPlaylistDialog = new OpenPlaylistDialog() this.setupDialog(this.openPlaylistDialog) - this.openPlaylistDialog.on('source selected', source => this.handlePlaylistSource(source)) - this.openPlaylistDialog.on('source selected (new tab)', source => this.handlePlaylistSource(source, true)) + this.openPlaylistDialog.on('source selected', source => this.loadPlaylistOrSource(source)) + this.openPlaylistDialog.on('source selected (new tab)', source => this.loadPlaylistOrSource(source, true)) this.alertDialog = new AlertDialog() this.setupDialog(this.alertDialog) @@ -944,7 +944,7 @@ class AppElement extends FocusElement { openSpecialOrThroughSystem(item) { if (item.url.endsWith('.json')) { - return this.handlePlaylistSource(item.url, true) + return this.loadPlaylistOrSource(item.url, true) /* } else if (item.url.endsWith('.txt')) { if (this.textInfoPane.visible) { @@ -1094,7 +1094,7 @@ class AppElement extends FocusElement { }) } - async handlePlaylistSource(source, newTab = false) { + async loadPlaylistOrSource(sourceOrPlaylist, newTab = false) { if (this.openPlaylistDialog.visible) { this.openPlaylistDialog.close() } @@ -1102,18 +1102,22 @@ class AppElement extends FocusElement { this.alertDialog.showMessage('Opening playlist...', false) let grouplike - try { - grouplike = await this.openPlaylist(source) - } catch (error) { - if (error === 'unknown argument') { - this.alertDialog.showMessage('Could not figure out how to load a playlist from: ' + source) - } else if (typeof error === 'string') { - this.alertDialog.showMessage(error) - } else { - throw error - } + if (typeof sourceOrPlaylist === 'object' && isGroup(sourceOrPlaylist) || sourceOrPlaylist.source) { + grouplike = sourceOrPlaylist + } else { + try { + grouplike = await this.openPlaylist(sourceOrPlaylist) + } catch (error) { + if (error === 'unknown argument') { + this.alertDialog.showMessage('Could not figure out how to load a playlist from: ' + sourceOrPlaylist) + } else if (typeof error === 'string') { + this.alertDialog.showMessage(error) + } else { + throw error + } - return + return + } } this.alertDialog.close() -- cgit 1.3.0-6-gf8a5 From befceed00ce677befe0ae9fa5122390e391207d4 Mon Sep 17 00:00:00 2001 From: Florrie Date: Thu, 9 Jul 2020 16:35:06 -0300 Subject: replace existing empty tab when loading playlist --- ui.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui.js b/ui.js index 41ab4cc..f333b7a 100644 --- a/ui.js +++ b/ui.js @@ -1124,7 +1124,7 @@ class AppElement extends FocusElement { grouplike = await processSmartPlaylist(grouplike) - if (newTab || !this.tabber.currentElement) { + if (!this.tabber.currentElement || newTab && this.tabber.currentElement.grouplike) { const grouplikeListing = this.newGrouplikeListing() grouplikeListing.loadGrouplike(grouplike) } else { -- cgit 1.3.0-6-gf8a5 From 06128c9d42060b7513fb5490982e7f42b0ee596b Mon Sep 17 00:00:00 2001 From: Florrie Date: Thu, 9 Jul 2020 17:07:41 -0300 Subject: add seekTo player function --- players.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/players.js b/players.js index 5fe4714..6624f0f 100644 --- a/players.js +++ b/players.js @@ -40,6 +40,7 @@ class Player extends EventEmitter { playFile(file) {} seekAhead(secs) {} seekBack(secs) {} + seekTo(timeInSecs) {} volUp(amount) {} volDown(amount) {} setVolume(value) {} @@ -189,6 +190,10 @@ module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer { this.sendCommand('seek', -secs) } + seekTo(timeInSecs) { + this.sendCommand('seek', timeInSecs, 'absolute') + } + volUp(amount) { this.setVolume(this.volume + amount) } -- cgit 1.3.0-6-gf8a5 From ca096c7a64b5098ac1548d0996e871f63934b7ae Mon Sep 17 00:00:00 2001 From: Florrie Date: Thu, 9 Jul 2020 17:09:02 -0300 Subject: don't show a divider as the first item in a menu --- ui.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ui.js b/ui.js index f333b7a..0499ff6 100644 --- a/ui.js +++ b/ui.js @@ -3836,12 +3836,16 @@ class ContextMenu extends FocusElement { let wantDivider = false const addDividerIfWanted = () => { if (wantDivider) { - const element = new HorizontalRule() - this.form.addInput(element) + if (!firstItem) { + const element = new HorizontalRule() + this.form.addInput(element) + } wantDivider = false } } + let firstItem = true + const keyElementMap = {} for (const item of items.filter(Boolean)) { @@ -3854,6 +3858,7 @@ class ContextMenu extends FocusElement { if (item.isDefault) { this.root.select(item.element) } + firstItem = false } else if (item.divider) { wantDivider = true } else { @@ -3875,6 +3880,7 @@ class ContextMenu extends FocusElement { if (item.isDefault) { this.root.select(button) } + firstItem = false } if (item.key) { keyElementMap[item.key] = focusEl -- cgit 1.3.0-6-gf8a5 From 20f8491545c3b598cc6bba893bc79db5cf6bcb1f Mon Sep 17 00:00:00 2001 From: Florrie Date: Thu, 9 Jul 2020 17:09:21 -0300 Subject: don't show Reveal option if track has no parent --- ui.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui.js b/ui.js index 0499ff6..de0b214 100644 --- a/ui.js +++ b/ui.js @@ -1024,7 +1024,7 @@ class AppElement extends FocusElement { let items; if (listing.grouplike.isTheQueue && isTrack(item)) { items = [ - this.tabberPane.visible && {label: 'Reveal', action: () => this.reveal(item)}, + 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)}, -- cgit 1.3.0-6-gf8a5 From 7ea3291db7dd2de13e9f07005e83524711f086af Mon Sep 17 00:00:00 2001 From: Florrie Date: Fri, 10 Jul 2020 11:24:23 -0300 Subject: rename telnet-server.js to telnet.js ...for consistency with a socket.js (coming soon to a theater near you!) --- index.js | 2 +- telnet-server.js | 81 -------------------------------------------------------- telnet.js | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 82 deletions(-) delete mode 100644 telnet-server.js create mode 100644 telnet.js diff --git a/index.js b/index.js index 4b7654d..f0cd4e3 100755 --- a/index.js +++ b/index.js @@ -7,7 +7,7 @@ const { getPlayer } = require('./players') const { parseOptions } = require('./general-util') const AppElement = require('./ui') const Backend = require('./backend') -const TelnetServer = require('./telnet-server') +const TelnetServer = require('./telnet') const processSmartPlaylist = require('./smart-playlist') const setupClient = require('./client') diff --git a/telnet-server.js b/telnet-server.js deleted file mode 100644 index 33e3dcc..0000000 --- a/telnet-server.js +++ /dev/null @@ -1,81 +0,0 @@ -'use strict' - -const EventEmitter = require('events') -const net = require('net') -const setupClient = require('./client') - -const { - util: { - TelnetInterfacer - } -} = require('tui-lib') - -class TelnetServer extends EventEmitter { - constructor(backend) { - super() - - this.backend = backend - this.server = new net.Server(socket => this.handleConnection(socket)) - this.sockets = [] - } - - listen(port) { - this.server.listen(port) - } - - async handleConnection(socket) { - const interfacer = new TelnetInterfacer(socket) - const { appElement, cleanTerminal, flushable } = await setupClient({ - backend: this.backend, - writable: socket, - interfacer, - appConfig: { - canControlPlayback: false, - canControlQueue: true, - canControlQueuePlayers: false, - canProcessMetadata: false, - canSuspend: false, - showTabberPane: true, - stopPlayingUponQuit: false, - menubarColor: 2 - } - }) - - appElement.attachAsServerClient(this) - - let closed = false - - const quit = (msg = 'See you!') => { - cleanTerminal() - interfacer.cleanTelnetOptions() - socket.write('\r' + msg + '\r\n') - socket.end() - flushable.end() - closed = true - this.sockets.splice(this.sockets.indexOf(socket), 1) - } - - appElement.on('quitRequested', quit) - - socket.on('close', () => { - if (!closed) { - flushable.end() - closed = true - this.sockets.splice(this.sockets.indexOf(socket), 1) - } - this.emit('left', socket) - }) - - socket.quit = quit - this.sockets.push(socket) - this.emit('joined', socket) - } - - disconnectAllSockets(msg) { - while (this.sockets.length) { - this.sockets[0].quit(msg) - } - } -} - -module.exports = TelnetServer diff --git a/telnet.js b/telnet.js new file mode 100644 index 0000000..33e3dcc --- /dev/null +++ b/telnet.js @@ -0,0 +1,81 @@ +'use strict' + +const EventEmitter = require('events') +const net = require('net') +const setupClient = require('./client') + +const { + util: { + TelnetInterfacer + } +} = require('tui-lib') + +class TelnetServer extends EventEmitter { + constructor(backend) { + super() + + this.backend = backend + this.server = new net.Server(socket => this.handleConnection(socket)) + this.sockets = [] + } + + listen(port) { + this.server.listen(port) + } + + async handleConnection(socket) { + const interfacer = new TelnetInterfacer(socket) + const { appElement, cleanTerminal, flushable } = await setupClient({ + backend: this.backend, + writable: socket, + interfacer, + appConfig: { + canControlPlayback: false, + canControlQueue: true, + canControlQueuePlayers: false, + canProcessMetadata: false, + canSuspend: false, + showTabberPane: true, + stopPlayingUponQuit: false, + menubarColor: 2 + } + }) + + appElement.attachAsServerClient(this) + + let closed = false + + const quit = (msg = 'See you!') => { + cleanTerminal() + interfacer.cleanTelnetOptions() + socket.write('\r' + msg + '\r\n') + socket.end() + flushable.end() + closed = true + this.sockets.splice(this.sockets.indexOf(socket), 1) + } + + appElement.on('quitRequested', quit) + + socket.on('close', () => { + if (!closed) { + flushable.end() + closed = true + this.sockets.splice(this.sockets.indexOf(socket), 1) + } + this.emit('left', socket) + }) + + socket.quit = quit + this.sockets.push(socket) + this.emit('joined', socket) + } + + disconnectAllSockets(msg) { + while (this.sockets.length) { + this.sockets[0].quit(msg) + } + } +} + +module.exports = TelnetServer -- cgit 1.3.0-6-gf8a5 From 8927fb3f3ef9a3d641cf607a756b8815f06c57e2 Mon Sep 17 00:00:00 2001 From: Florrie Date: Fri, 10 Jul 2020 11:25:59 -0300 Subject: update old process.argv checks to use parseOptions --- index.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/index.js b/index.js index f0cd4e3..444d579 100755 --- a/index.js +++ b/index.js @@ -65,9 +65,9 @@ async function main() { } } }, - 'player-options': { - type: 'series' - }, + 'player-options': {type: 'series'}, + 'stress-test': {type: 'flag'}, + 'telnet-server': {type: 'flag'}, [parseOptions.handleDashless](option) { playlistSources.push(option) } @@ -132,13 +132,13 @@ async function main() { const loadPlaylistPromise = loadPlaylists() let telnetServer - if (process.argv.includes('--telnet-server')) { + if (options['telnet-server']) { telnetServer = new TelnetServer(backend) await telnetServer.listen(1244) appElement.attachAsServerHost(telnetServer) } - if (process.argv.includes('--stress-test')) { + if (options['stress-test']) { await loadPlaylistPromise const w = 80 -- cgit 1.3.0-6-gf8a5 From ba828b4f12779c1ce356ea2c0d99bcb891a09aa0 Mon Sep 17 00:00:00 2001 From: Florrie Date: Fri, 17 Jul 2020 19:02:18 -0300 Subject: use better package for natural sorting --- crawlers.js | 10 ++-------- package-lock.json | 10 +++++----- package.json | 2 +- todo.txt | 1 + 4 files changed, 9 insertions(+), 14 deletions(-) diff --git a/crawlers.js b/crawlers.js index e39dfa1..3f6e391 100644 --- a/crawlers.js +++ b/crawlers.js @@ -1,11 +1,11 @@ const fs = require('fs') const path = require('path') -const naturalSort = require('node-natural-sort') 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) @@ -20,12 +20,6 @@ const stat = promisify(fs.stat) // getAllCrawlersForArg. const allCrawlers = {} -function sortIgnoreCase(sortFunction) { - return function(a, b) { - return sortFunction(a.toLowerCase(), b.toLowerCase()) - } -} - /* TODO: Removed cheerio, so crawl-http no longer works. function crawlHTTP(absURL, opts = {}, internals = {}) { // Recursively crawls a given URL, following every link to a deeper path and @@ -254,7 +248,7 @@ function crawlLocal(dirPath, extensions = [ } return readDir(dirPath).then(items => { - items.sort(sortIgnoreCase(naturalSort())) + items = orderBy(items) return Promise.all(items.map(item => { const itemPath = path.join(dirPath, item) diff --git a/package-lock.json b/package-lock.json index a4ec0d1..592e796 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,16 +55,16 @@ "minimist": "^1.2.5" } }, + "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-fetch": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==" }, - "node-natural-sort": { - "version": "0.8.7", - "resolved": "https://registry.npmjs.org/node-natural-sort/-/node-natural-sort-0.8.7.tgz", - "integrity": "sha512-rMaLlHV5BlnRhIl6jUfgqdLY5U0NJkIxUdOsmpz3Txwh7js4+GwTiomhO8W4rp3SvX1zZ56mx13zfEWESr+qqA==" - }, "open": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/open/-/open-7.0.3.tgz", diff --git a/package.json b/package.json index 7e33ce0..421b0a4 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "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", - "node-natural-sort": "^0.8.7", "open": "^7.0.3", "sanitize-filename": "^1.6.3", "tempy": "^0.2.1", diff --git a/todo.txt b/todo.txt index 98a57a8..c58c1cb 100644 --- a/todo.txt +++ b/todo.txt @@ -529,6 +529,7 @@ TODO: A "before selected item" option for in the queue menu! TODO: The sorting for library3/C418 seems to be weird???? Could be pointing to some bug! + (Done! Using a better package for sorting now.) TODO: Selecting a group from the path listing at the bottom of listings should make it so the child of that group matching with the path is selected. -- cgit 1.3.0-6-gf8a5 From 5d58951225dba66402fc113d390ec602043d263e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 15 Sep 2020 15:51:42 -0300 Subject: better hiding track numbering in queue --- playlist-utils.js | 80 +++++++++++++++++++++++++++++++++++++++---------------- todo.txt | 2 ++ ui.js | 7 +++++ 3 files changed, 66 insertions(+), 23 deletions(-) diff --git a/playlist-utils.js b/playlist-utils.js index 452b705..68cba56 100644 --- a/playlist-utils.js +++ b/playlist-utils.js @@ -520,36 +520,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/todo.txt b/todo.txt index c58c1cb..b210799 100644 --- a/todo.txt +++ b/todo.txt @@ -462,9 +462,11 @@ TODO: Only count *consistently* formatted text, across all tracks in a group, it is the only track in the group which is formatted '## # '. It does follow the formatting '## ' 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!) diff --git a/ui.js b/ui.js index de0b214..5e6ef6e 100644 --- a/ui.js +++ b/ui.js @@ -60,6 +60,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') @@ -317,6 +318,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) @@ -1475,6 +1477,11 @@ class AppElement extends FocusElement { item = {items: flattenGrouplike(item).items.reverse()} } else if (order === 'reverse-groups') { item = reverseOrderOfGroups(item) + } else if (order === 'alphabetic') { + item = { + name: `${oldName} (alphabetic)`, + items: orderBy(flattenGrouplike(item).items, getNameWithoutTrackNumber) + } } } else { // Make it into a grouplike that just contains itself. -- cgit 1.3.0-6-gf8a5 From 7c8cb5f3df821719a4187ca4c0fe3fb455f9529a Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 15 Sep 2020 15:55:43 -0300 Subject: name reordered groups for queuing cherry-picked in part from 4b171a6a! --- ui.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/ui.js b/ui.js index 5e6ef6e..ae3cf32 100644 --- a/ui.js +++ b/ui.js @@ -1468,15 +1468,24 @@ class AppElement extends FocusElement { this.SQP.playNext(playingTrack) } + const oldName = item.name if (isGroup(item)) { if (order === 'shuffle') { - item = {items: shuffleArray(flattenGrouplike(item).items)} + 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 = {items: flattenGrouplike(item).items.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)`, @@ -1485,7 +1494,7 @@ class AppElement extends FocusElement { } } else { // Make it into a grouplike that just contains itself. - item = {items: [item]} + item = {name: oldName, items: [item]} } if (where === 'next' || where === 'next-selected' || where === 'end') { -- cgit 1.3.0-6-gf8a5 From 771d789e42a908a8b8e086c5a97bbf180538894e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 15 Sep 2020 16:53:59 -0300 Subject: show child when opening group from path element --- todo.txt | 1 + ui.js | 15 ++++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/todo.txt b/todo.txt index b210799..18ed0c9 100644 --- a/todo.txt +++ b/todo.txt @@ -538,6 +538,7 @@ 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! diff --git a/ui.js b/ui.js index ae3cf32..85cefcd 100644 --- a/ui.js +++ b/ui.js @@ -737,7 +737,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)) @@ -758,7 +758,7 @@ class AppElement extends FocusElement { return menu } - reveal(item) { + reveal(item, child) { if (!this.tabberPane.visible) { return } @@ -769,6 +769,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) @@ -3080,10 +3083,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) } -- cgit 1.3.0-6-gf8a5 From 90116fa6c67e4c3262d0e98079acdf5cd984f641 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 15 Sep 2020 16:59:33 -0300 Subject: "Before selected [track]" queue option note: this is actually "Before selected song" but the next commit changes much-outdated "song" terminology in the UI to "track" --- todo.txt | 3 +++ ui.js | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/todo.txt b/todo.txt index 18ed0c9..d8e9e3b 100644 --- a/todo.txt +++ b/todo.txt @@ -528,6 +528,7 @@ TODO: Deselecting a grouplike listing (e.g. by clicking elsewhere) should hide its "jump to" element. 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! @@ -543,3 +544,5 @@ TODO: Selecting a group from the path listing at the bottom of listings should 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. diff --git a/ui.js b/ui.js index 85cefcd..0f20bc5 100644 --- a/ui.js +++ b/ui.js @@ -306,11 +306,12 @@ class AppElement extends FocusElement { this.addChild(this.menuLayer) this.whereControl = new InlineListPickerElement('Where?', [ - {value: 'next-selected', label: 'After selected song'}, + {value: 'after-selected', label: 'After selected song'}, {value: 'next', label: 'After current song'}, {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 song'} ], this.showContextMenu) this.orderControl = new InlineListPickerElement('Order?', [ @@ -1500,12 +1501,20 @@ class AppElement extends FocusElement { item = {name: oldName, items: [item]} } - if (where === 'next' || where === 'next-selected' || where === 'end') { + if (where === 'next' || where === 'after-selected' || where === 'before-selected' || where === 'end') { let afterItem = null if (where === 'next') { afterItem = playingTrack - } else if (where === 'next-selected') { + } else if (where === 'after-selected') { afterItem = this.queueListingElement.currentItem + } else if (where === 'before-selected') { + const { items } = this.SQP.queueGrouplike + const index = items.indexOf(this.queueListingElement.currentItem) + if (index === 0) { + afterItem = 'FRONT' + } else if (index > 0) { + afterItem = items[index - 1] + } } this.SQP.queue(item, afterItem, { -- cgit 1.3.0-6-gf8a5 From 05d29dff7b14bc1a1ea641bbb03314ec308610df Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 15 Sep 2020 17:01:58 -0300 Subject: rename "song" terminology to "track" We've always used "track" as the proper term, but these managed to slip by over time anyway. Oops! --- client.js | 2 +- players.js | 2 +- todo.txt | 1 + ui.js | 6 +++--- 4 files changed, 6 insertions(+), 5 deletions(-) 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/players.js b/players.js index 6624f0f..e22e505 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/todo.txt b/todo.txt index d8e9e3b..0be297c 100644 --- a/todo.txt +++ b/todo.txt @@ -546,3 +546,4 @@ 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!) diff --git a/ui.js b/ui.js index 0f20bc5..642a02a 100644 --- a/ui.js +++ b/ui.js @@ -306,12 +306,12 @@ class AppElement extends FocusElement { this.addChild(this.menuLayer) this.whereControl = new InlineListPickerElement('Where?', [ - {value: 'after-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: 'before-selected', label: 'Before selected song'} + {value: 'before-selected', label: 'Before selected track'} ], this.showContextMenu) this.orderControl = new InlineListPickerElement('Order?', [ -- cgit 1.3.0-6-gf8a5 From 466582b32b1b3749d30c24827a934d8e826723f3 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 15 Sep 2020 17:14:09 -0300 Subject: update screenshot for 2020 It's apparently been just over exactly two years since the last screenshot update! (1daa3b0f41903341aedfd08905c867b933486aed) --- screenshot.png | Bin 56156 -> 78417 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/screenshot.png b/screenshot.png index e7abe90..81bab72 100644 Binary files a/screenshot.png and b/screenshot.png differ -- cgit 1.3.0-6-gf8a5 From 60f33193fb78c8154a1b112d0faf76af34288df8 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 16 Sep 2020 12:52:40 -0300 Subject: cancel jump-to when selecting outside of listing --- todo.txt | 1 + ui.js | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/todo.txt b/todo.txt index 0be297c..0a478b7 100644 --- a/todo.txt +++ b/todo.txt @@ -526,6 +526,7 @@ 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!) diff --git a/ui.js b/ui.js index 642a02a..5a801de 100644 --- a/ui.js +++ b/ui.js @@ -2070,6 +2070,10 @@ class GrouplikeListingElement extends Form { this.fixLayout() } + unselected() { + this.hideJumpElement(true) + } + get tabberLabel() { if (this.grouplike) { return this.grouplike.name || 'Unnamed group' -- cgit 1.3.0-6-gf8a5 From e0d244aad8e1e125fba5e297a115233148afa8af Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 16 Sep 2020 12:55:03 -0300 Subject: todo updates --- todo.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/todo.txt b/todo.txt index 0a478b7..1f7de4d 100644 --- a/todo.txt +++ b/todo.txt @@ -483,11 +483,14 @@ TODO: Opening the selection contxt menu should show an option to either add or selection accessible when a keyboard or the shift key is inaccessible. 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. -- cgit 1.3.0-6-gf8a5 From 82b243df1a54eaf4be83faa2ac6fbe2a33ffe4bf Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 16 Sep 2020 13:28:28 -0300 Subject: fix jump-to cancel being very broken --- ui.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/ui.js b/ui.js index 5a801de..4b45bc9 100644 --- a/ui.js +++ b/ui.js @@ -2059,15 +2059,17 @@ 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() { -- cgit 1.3.0-6-gf8a5 From a8b64d31462aeb65c9286140f37f5c71a9965917 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 16 Sep 2020 13:29:40 -0300 Subject: multi-page menu support --- ui.js | 216 +++++++++++++++++++++++++++++++++++++++++------------------------- 1 file changed, 134 insertions(+), 82 deletions(-) diff --git a/ui.js b/ui.js index 4b45bc9..d948208 100644 --- a/ui.js +++ b/ui.js @@ -1004,99 +1004,99 @@ 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', action: () => this.deselectAll()} + ] + } } + 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 }) } @@ -3842,7 +3842,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])) @@ -3850,6 +3850,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) @@ -3899,8 +3932,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() @@ -3911,6 +3948,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) { @@ -3961,6 +4004,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) } -- cgit 1.3.0-6-gf8a5 From 3233c5ba82b89a3aae68081dd8cf9f8fa4282b60 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 16 Sep 2020 14:32:39 -0300 Subject: rework mark/selection system No more issues with duplicate tracks, and way more power to the user regardless of the interface they use or their experience with the mark/selection system! --- todo.txt | 9 ++++ ui.js | 162 ++++++++++++++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 134 insertions(+), 37 deletions(-) diff --git a/todo.txt b/todo.txt index 1f7de4d..75de2f2 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 @@ -481,6 +489,7 @@ 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!) diff --git a/ui.js b/ui.js index d948208..c734a74 100644 --- a/ui.js +++ b/ui.js @@ -204,6 +204,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). @@ -846,8 +847,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() { @@ -1080,7 +1146,12 @@ class AppElement extends FocusElement { canControlQueue && isPlayable(item) && {label: 'Remove from queue', action: () => this.unqueue(item)}, {divider: true}, - item === this.markGrouplike && {label: 'Deselect', action: () => this.deselectAll()} + ...(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)} + ]) ] } } @@ -1802,7 +1873,7 @@ class GrouplikeListingElement extends Form { */ } } else if (keyBuf[0] === 1) { // ctrl-A - this.toggleSelectAll() + this.toggleMarkAll() } else { return super.keyPressed(keyBuf) } @@ -1843,14 +1914,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() } /* @@ -2153,13 +2244,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] @@ -2196,27 +2287,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) } } } @@ -2255,11 +2341,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) } @@ -2972,21 +3060,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])) @@ -3000,8 +3090,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(' ') } @@ -3021,10 +3113,6 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement { writable.write(' ') } - get isMarked() { - return this.app.markGrouplike.items.includes(this.item) - } - get isGroup() { return isGroup(this.item) } -- cgit 1.3.0-6-gf8a5 From 880e8b9b16930a9db78f162f37c6b729b6c9c814 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 15 Oct 2020 21:37:29 -0300 Subject: fix the guessing game lol --- guess.js | 41 +++++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 18 deletions(-) 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 + '') write(ansi.resetAttributes()) } else { + console.log(resetLine + dim + ``) playTime += Date.now() - startTime - console.log(resetLine + dim + '') 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 { -- cgit 1.3.0-6-gf8a5 From 942c36453fc9317056791acade02067f01c1464d Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 16 Feb 2021 20:12:55 -0400 Subject: loop queue --- backend.js | 20 +++++++++++++++++++- ui.js | 7 +++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/backend.js b/backend.js index 69aa815..81726c6 100644 --- a/backend.js +++ b/backend.js @@ -66,6 +66,7 @@ class QueuePlayer extends EventEmitter { this.playingTrack = null this.queueGrouplike = {name: 'Queue', isTheQueue: true, items: []} this.pauseNextTrack = false + this.loopQueueAtEnd = false this.playedTrackToEnd = false this.timeData = null @@ -433,7 +434,11 @@ class QueuePlayer extends EventEmitter { if (playingThisTrack) { this.playedTrackToEnd = true if (!this.playNext(item)) { - this.clearPlayingTrack() + if (this.loopQueueAtEnd) { + this.playFirst() + } else { + this.clearPlayingTrack() + } } } } @@ -496,6 +501,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 @@ -557,6 +571,10 @@ class QueuePlayer extends EventEmitter { this.pauseNextTrack = !!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/ui.js b/ui.js index c734a74..5a8e0ad 100644 --- a/ui.js +++ b/ui.js @@ -343,6 +343,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}, @@ -398,6 +399,12 @@ class AppElement extends FocusElement { getEnabled: () => this.config.canControlPlayback }) + this.loopQueueControl = new ToggleControl('Loop queue when it ends?', { + 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, -- cgit 1.3.0-6-gf8a5 From 9ab44f1d8c0986986ac5d5df102d69aa21040f39 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 25 Feb 2021 11:51:34 -0400 Subject: loop queue "when it ends" is unnecessary lol --- ui.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui.js b/ui.js index 5a8e0ad..5e9e6f9 100644 --- a/ui.js +++ b/ui.js @@ -399,7 +399,7 @@ class AppElement extends FocusElement { getEnabled: () => this.config.canControlPlayback }) - this.loopQueueControl = new ToggleControl('Loop queue when it ends?', { + this.loopQueueControl = new ToggleControl('Loop queue?', { setValue: val => this.SQP.setLoopQueueAtEnd(val), getValue: () => this.SQP.loopQueueAtEnd, getEnabled: () => this.config.canControlPlayback -- cgit 1.3.0-6-gf8a5 From 1fb903a227094b5995d807f7bfc1c3bb414b37e5 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 15 Mar 2021 20:34:32 -0300 Subject: count only alphanumeric symbols in alphabetic sort --- ui.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui.js b/ui.js index 5e9e6f9..8945ce0 100644 --- a/ui.js +++ b/ui.js @@ -1571,7 +1571,10 @@ class AppElement extends FocusElement { } else if (order === 'alphabetic') { item = { name: `${oldName} (alphabetic)`, - items: orderBy(flattenGrouplike(item).items, getNameWithoutTrackNumber) + items: orderBy( + flattenGrouplike(item).items, + t => getNameWithoutTrackNumber(t).replace(/[^a-zA-Z0-9]/g, '') + ) } } } else { -- cgit 1.3.0-6-gf8a5 From c542bc1a2acca7c62f9556499945794878789e3e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 15 Mar 2021 20:35:31 -0300 Subject: queue sorting fixes re: selected track not 100% sure what these are for since i wrote this patch ages ago! sorry :3 --- ui.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ui.js b/ui.js index 8945ce0..20cfd99 100644 --- a/ui.js +++ b/ui.js @@ -1583,14 +1583,15 @@ class AppElement extends FocusElement { } 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 === 'after-selected') { - afterItem = this.queueListingElement.currentItem + afterItem = selected } else if (where === 'before-selected') { const { items } = this.SQP.queueGrouplike - const index = items.indexOf(this.queueListingElement.currentItem) + const index = items.indexOf(selected) if (index === 0) { afterItem = 'FRONT' } else if (index > 0) { @@ -1599,11 +1600,13 @@ class AppElement extends FocusElement { } 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, { -- cgit 1.3.0-6-gf8a5 From 45450549c34ecfdcb6082eeb11f18b5a005d3eb4 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 15 Mar 2021 20:36:10 -0300 Subject: add (0) key for opening menu (for numpad use) --- ui.js | 1 + 1 file changed, 1 insertion(+) diff --git a/ui.js b/ui.js index 20cfd99..de73a81 100644 --- a/ui.js +++ b/ui.js @@ -131,6 +131,7 @@ const keyBindings = [ ['isTogglePause', '5'], ['isBackspace', '.'], ['isMenu', '+'], + ['isMenu', '0'], ['isSkipBack', '1'], ['isSkipAhead', '3'], // Disabled because this is the jump key! Oops. -- cgit 1.3.0-6-gf8a5 From e13e8790feaaa736aab69191996734a14c808012 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 15 Mar 2021 20:36:35 -0300 Subject: todo updates! --- todo.txt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/todo.txt b/todo.txt index 75de2f2..34784cc 100644 --- a/todo.txt +++ b/todo.txt @@ -560,3 +560,19 @@ 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. -- cgit 1.3.0-6-gf8a5