diff options
-rw-r--r-- | backend.js | 128 | ||||
-rw-r--r-- | general-util.js | 14 | ||||
-rwxr-xr-x | index.js | 41 | ||||
-rw-r--r-- | package-lock.json | 260 | ||||
-rw-r--r-- | package.json | 7 | ||||
-rw-r--r-- | players.js | 10 | ||||
-rw-r--r-- | playlist-utils.js | 281 | ||||
-rw-r--r-- | serialized-backend.js | 245 | ||||
-rw-r--r-- | socket.js | 720 | ||||
-rw-r--r-- | ui.js | 122 |
10 files changed, 1757 insertions, 71 deletions
diff --git a/backend.js b/backend.js index 81726c6..d222d10 100644 --- a/backend.js +++ b/backend.js @@ -8,6 +8,7 @@ const { getMetadataReaderFor } = require('./metadata-readers') const { getPlayer } = require('./players') const RecordStore = require('./record-store') const os = require('os') +const shortid = require('shortid') const { getTimeStringsFromSec, @@ -62,6 +63,8 @@ class QueuePlayer extends EventEmitter { }) { super() + this.id = shortid.generate() + this.player = null this.playingTrack = null this.queueGrouplike = {name: 'Queue', isTheQueue: true, items: []} @@ -69,6 +72,10 @@ class QueuePlayer extends EventEmitter { this.loopQueueAtEnd = false this.playedTrackToEnd = false this.timeData = null + this.time = null + + this.alwaysStartPaused = false + this.waitWhenDonePlaying = false this.getPlayer = getPlayer this.getRecordFor = getRecordFor @@ -86,6 +93,7 @@ class QueuePlayer extends EventEmitter { this.player.on('printStatusLine', data => { if (this.playingTrack) { this.timeData = data + this.time = data.curSecTotal this.emit('received time data', data, this) } }) @@ -161,6 +169,7 @@ class QueuePlayer extends EventEmitter { } recursivelyAddTracks(topItem) + this.emit('queue', topItem, afterItem, {movePlayingTrack}) this.emitQueueUpdated() // This is the first new track, if a group was queued. @@ -169,9 +178,12 @@ class QueuePlayer extends EventEmitter { return newTrack } - distributeQueue(grouplike, {how = 'evenly', rangeEnd = 'end-of-queue'}) { - if (isTrack(grouplike)) { - grouplike = {items: [grouplike]} + distributeQueue(topItem, {how = 'evenly', rangeEnd = 'end-of-queue'} = {}) { + let grouplike + if (isTrack(topItem)) { + grouplike = {items: [topItem]} + } else { + grouplike = topItem } const { items } = this.queueGrouplike @@ -237,6 +249,7 @@ class QueuePlayer extends EventEmitter { } } + this.emit('distribute-queue', topItem, {how, rangeEnd}) this.emitQueueUpdated() } @@ -281,11 +294,17 @@ class QueuePlayer extends EventEmitter { } recursivelyUnqueueTracks(topItem) + this.emit('unqueue', topItem) this.emitQueueUpdated() return focusItem } + replaceAllItems(newItems) { + this.queueGrouplike.items = newItems + this.emitQueueUpdated() + } + clearQueuePast(track) { const { items } = this.queueGrouplike const index = items.indexOf(track) + 1 @@ -298,6 +317,7 @@ class QueuePlayer extends EventEmitter { items.splice(index) } + this.emit('clear-queue-past', track) this.emitQueueUpdated() } @@ -314,6 +334,7 @@ class QueuePlayer extends EventEmitter { items.splice(startIndex, endIndex - startIndex) } + this.emit('clear-queue-up-to', track) this.emitQueueUpdated() } @@ -344,6 +365,7 @@ class QueuePlayer extends EventEmitter { const remainingItems = queue.items.slice(index) const newItems = initialItems.concat(shuffleArray(remainingItems)) queue.items = newItems + this.emit('shuffle-queue') this.emitQueueUpdated() } @@ -352,6 +374,7 @@ class QueuePlayer extends EventEmitter { // the track that's currently playing). this.queueGrouplike.items = this.queueGrouplike.items .filter(item => item === this.playingTrack) + this.emit('clear-queue') this.emitQueueUpdated() } @@ -368,7 +391,7 @@ class QueuePlayer extends EventEmitter { } - async play(item) { + async play(item, forceStartPaused) { if (this.player === null) { throw new Error('Attempted to play before a player was loaded') } @@ -414,11 +437,15 @@ class QueuePlayer extends EventEmitter { } this.timeData = null + this.time = null this.playingTrack = item + this.emit('playing details', this.playingTrack, oldTrack, this) this.emit('playing', this.playingTrack, oldTrack, this) await this.player.kill() - if (this.playedTrackToEnd) { + if (this.alwaysStartPaused || forceStartPaused) { + this.player.setPause(true) + } else if (this.playedTrackToEnd) { this.player.setPause(this.pauseNextTrack) this.pauseNextTrack = false this.playedTrackToEnd = false @@ -433,11 +460,14 @@ class QueuePlayer extends EventEmitter { if (playingThisTrack) { this.playedTrackToEnd = true - if (!this.playNext(item)) { - if (this.loopQueueAtEnd) { - this.playFirst() - } else { - this.clearPlayingTrack() + this.emit('done playing', this.playingTrack) + if (!this.waitWhenDonePlaying) { + if (!this.playNext(item)) { + if (this.loopQueueAtEnd) { + this.playFirst() + } else { + this.clearPlayingTrack() + } } } } @@ -515,6 +545,8 @@ class QueuePlayer extends EventEmitter { const oldTrack = this.playingTrack this.playingTrack = null this.timeData = null + this.time = null + this.emit('playing details', null, oldTrack, this) this.emit('playing', null, oldTrack, this) } } @@ -524,51 +556,73 @@ class QueuePlayer extends EventEmitter { } seekAhead(seconds) { + this.time += seconds this.player.seekAhead(seconds) + this.emit('seek-ahead', +seconds) } seekBack(seconds) { + if (this.time < seconds) { + this.time = 0 + } else { + this.time -= seconds + } this.player.seekBack(seconds) + this.emit('seek-back', +seconds) + } + + seekTo(timeInSecs) { + this.time = timeInSecs + this.player.seekTo(timeInSecs) + this.emit('seek-to', +timeInSecs) } togglePause() { this.player.togglePause() + this.emit('toggle-pause') } setPause(value) { this.player.setPause(value) + this.emit('set-pause', !!value) } toggleLoop() { this.player.toggleLoop() + this.emit('toggle-loop') } setLoop(value) { this.player.setLoop(value) + this.emit('set-loop', !!value) } volUp(amount = 10) { this.player.volUp(amount) + this.emit('vol-up', +amount) } volDown(amount = 10) { this.player.volDown(amount) + this.emit('vol-down', +amount) } setVolume(value) { this.player.setVolume(value) + this.emit('set-volume', +value) } setVolumeMultiplier(value) { - this.player.setVolumeMultiplier(value); + this.player.setVolumeMultiplier(value) } fadeIn() { - return this.player.fadeIn(); + return this.player.fadeIn() } setPauseNextTrack(value) { this.pauseNextTrack = !!value + this.emit('set-pause-next-track', !!value) } setLoopQueueAtEnd(value) { @@ -613,6 +667,8 @@ class Backend extends EventEmitter { } this.queuePlayers = [] + this.alwaysStartPaused = false + this.waitWhenDonePlaying = false this.recordStore = new RecordStore() this.throttleMetadata = throttlePromise(10) @@ -644,9 +700,39 @@ class Backend extends EventEmitter { return error } + queuePlayer.alwaysStartPaused = this.alwaysStartPaused + queuePlayer.waitWhenDonePlaying = this.waitWhenDonePlaying + this.queuePlayers.push(queuePlayer) this.emit('added queue player', queuePlayer) + for (const event of [ + 'playing', + 'done playing', + 'queue', + 'distribute-queue', + 'unqueue', + 'clear-queue-past', + 'clear-queue-up-to', + 'shuffle-queue', + 'clear-queue', + 'queue updated', + 'seek-ahead', + 'seek-back', + 'toggle-pause', + 'set-pause', + 'toggle-loop', + 'set-loop', + 'vol-up', + 'vol-down', + 'set-volume', + 'set-pause-next-track' + ]) { + queuePlayer.on(event, (...data) => { + this.emit(event, queuePlayer, ...data) + }) + } + return queuePlayer } @@ -743,6 +829,20 @@ class Backend extends EventEmitter { return {seconds, string, noticedMissingMetadata, approxSymbol} } + setAlwaysStartPaused(value) { + this.alwaysStartPaused = !!value + for (const queuePlayer of this.queuePlayers) { + queuePlayer.alwaysStartPaused = !!value + } + } + + setWaitWhenDonePlaying(value) { + this.waitWhenDonePlaying = !!value + for (const queuePlayer of this.queuePlayers) { + queuePlayer.waitWhenDonePlaying = !!value + } + } + async stopPlayingAll() { for (const queuePlayer of this.queuePlayers) { await queuePlayer.stopPlaying() @@ -752,6 +852,10 @@ class Backend extends EventEmitter { async download(item) { return download(item, this.getRecordFor(item)) } + + showLogMessage(text) { + this.emit('log message', text) + } } module.exports = Backend diff --git a/general-util.js b/general-util.js index 0a81cdc..0f5bdd5 100644 --- a/general-util.js +++ b/general-util.js @@ -310,3 +310,17 @@ const parseOptions = async function(options, optionDescriptorMap) { parseOptions.handleDashless = Symbol() module.exports.parseOptions = parseOptions + +module.exports.silenceEvents = async function(emitter, eventsToSilence, callback) { + const oldEmit = emitter.emit + + emitter.emit = function(event, ...data) { + if (!eventsToSilence.includes(event)) { + oldEmit.apply(emitter, [event, ...data]) + } + } + + await callback() + + emitter.emit = oldEmit +} diff --git a/index.js b/index.js index 444d579..d628a20 100755 --- a/index.js +++ b/index.js @@ -12,6 +12,13 @@ const processSmartPlaylist = require('./smart-playlist') const setupClient = require('./client') const { + makeSocketServer, + makeSocketClient, + attachBackendToSocketClient, + attachSocketServerToBackend +} = require('./socket') + +const { getItemPathString, updatePlaylistFormat } = require('./playlist-utils') @@ -67,6 +74,9 @@ async function main() { }, 'player-options': {type: 'series'}, 'stress-test': {type: 'flag'}, + 'socket-client': {type: 'value'}, + 'socket-name': {type: 'value'}, + 'socket-server': {type: 'value'}, 'telnet-server': {type: 'flag'}, [parseOptions.handleDashless](option) { playlistSources.push(option) @@ -138,6 +148,37 @@ async function main() { appElement.attachAsServerHost(telnetServer) } + let socketClient + let socketServer + if (options['socket-server']) { + socketServer = makeSocketServer() + attachSocketServerToBackend(socketServer, backend) + socketServer.listen(options['socket-server']) + + socketClient = makeSocketClient() + socketClient.socket.connect(options['socket-server']) + } + + if (options['socket-client']) { + socketClient = makeSocketClient() + const [ p1, p2 ] = options['socket-client'].split(':') + const host = p2 && p1 + const port = p2 ? p2 : p1 + socketClient.socket.connect(port, host) + } + + if (socketClient) { + attachBackendToSocketClient(backend, socketClient, { + getPlaylistSources: () => appElement.playlistSources + }) + + let nickname = process.env.USER + if (options['socket-name']) { + nickname = options['socket-name'] + } + socketClient.setNickname(nickname) + } + if (options['stress-test']) { await loadPlaylistPromise diff --git a/package-lock.json b/package-lock.json index 592e796..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", @@ -38,9 +237,12 @@ "integrity": "sha512-pJEdRugimx4fBMra5z2/5iRdZ63OhYV0vr0Dwm5+xtW4D1FvRkB8hamMIhnWfyJeDdyr/aa7BDyNbtG38VxgoQ==" }, "is-wsl": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.1.1.tgz", - "integrity": "sha512-umZHcSrwlDHo2TGMXv0DZ8dIUGunZ2Iv68YZnrmCiBPkZ4aaOhtv7pXJKeki9k3qJ3RJr0cDyitcl5wEH3AYog==" + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "requires": { + "is-docker": "^2.0.0" + } }, "minimist": { "version": "1.2.5", @@ -55,6 +257,11 @@ "minimist": "^1.2.5" } }, + "nanoid": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-2.1.11.tgz", + "integrity": "sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA==" + }, "natural-orderby": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-2.0.3.tgz", @@ -66,9 +273,9 @@ "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==" }, "open": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/open/-/open-7.0.3.tgz", - "integrity": "sha512-sP2ru2v0P290WFfv49Ap8MF6PkzGNnGlAwHweB4WR4mr5d2d0woiCluUeJ218w7/+PmoBy9JmYgD5A4mLcWOFA==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/open/-/open-7.0.4.tgz", + "integrity": "sha512-brSA+/yq+b08Hsr4c8fsEW2CRzk1BmfN3SAK/5VCHQ9bdoZJ4qa/+AfR0xHjlbbZUyPkUHs1b8x1RqdyZdkVqQ==", "requires": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" @@ -82,6 +289,14 @@ "truncate-utf8-bytes": "^1.0.0" } }, + "shortid": { + "version": "2.2.15", + "resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.15.tgz", + "integrity": "sha512-5EaCy2mx2Jgc/Fdn9uuDuNIIfWBpzY4XIlhoqtXF6qsf+/+SGZ+FxDdX/ZsMZiWupIWNqAEmiNY4RC+LSmCeOw==", + "requires": { + "nanoid": "^2.1.0" + } + }, "temp-dir": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", @@ -105,31 +320,11 @@ } }, "tui-lib": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.2.1.tgz", - "integrity": "sha512-AHyhA9neF8tM5dAJnggKIO1W0w5pSVjuuYryp/bMJee6ol2kIzd8p4mbri0Es6/BP9bvPdYFjhSddWwzAE0TpQ==", - "requires": { - "wcwidth": "^1.0.1", - "word-wrap": "^1.2.3" - } - }, - "tui-text-editor": { "version": "0.3.1", - "resolved": "https://registry.npmjs.org/tui-text-editor/-/tui-text-editor-0.3.1.tgz", - "integrity": "sha512-ySLdKfUHwxt6W1hub7Qt7smtuwujRHWxMIwdnO+IOzhd2B9naIg07JDr2LISZ3X+SZg0mvBNcGGeTf+L8bcSpw==", + "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.3.1.tgz", + "integrity": "sha512-uCE2j351/b4C2Q3eEhC54EvZiWbgJ/Q3gH5ElS2D+mvRmWbHDzXbPUhcXrx8oOA5rZFZ4iNVMCoLCqzWWZTJyQ==", "requires": { - "tui-lib": "^0.1.1" - }, - "dependencies": { - "tui-lib": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.1.1.tgz", - "integrity": "sha512-QAE4axNCJ42IZSNnc2pLOkFtzHqYFgenDyw88JHHRNd8PXTVO8+JIpJArpgAguopd4MmoYaJbreze0BHoWMXfA==", - "requires": { - "wcwidth": "^1.0.1", - "word-wrap": "^1.2.3" - } - } + "wcwidth": "^1.0.1" } }, "unique-string": { @@ -152,11 +347,6 @@ "requires": { "defaults": "^1.0.3" } - }, - "word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==" } } } diff --git a/package.json b/package.json index 421b0a4..1cbcb9c 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,10 @@ "mkdirp": "^0.5.5", "natural-orderby": "^2.0.3", "node-fetch": "^2.6.0", - "open": "^7.0.3", + "open": "^7.0.4", "sanitize-filename": "^1.6.3", + "shortid": "^2.2.15", "tempy": "^0.2.1", - "tui-lib": "^0.2.1", - "tui-text-editor": "^0.3.1", - "word-wrap": "^1.2.3" + "tui-lib": "^0.3.1" } } diff --git a/players.js b/players.js index e22e505..dde1fbf 100644 --- a/players.js +++ b/players.js @@ -224,13 +224,15 @@ module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer { } setPause(val) { - this.isPaused = !!val - this.sendCommand('set', 'pause', this.isPaused) + if (!!val !== this.isPaused) { + this.togglePause() + } } setLoop(val) { - this.isLooping = !!val - this.sendCommand('set', 'loop', this.isLooping) + if (!!val !== this.isLooping) { + this.toggleLoop() + } } async kill() { diff --git a/playlist-utils.js b/playlist-utils.js index 68cba56..5fbfff8 100644 --- a/playlist-utils.js +++ b/playlist-utils.js @@ -166,15 +166,30 @@ function flattenGrouplike(grouplike) { // levels in the group tree and returns them as a new group containing those // tracks. - return { - items: grouplike.items.map(item => { - if (isGroup(item)) { - return flattenGrouplike(item).items - } else { - return [item] - } - }).reduce((a, b) => a.concat(b), []) - } + return {items: getFlatTrackList(grouplike)} +} + +function getFlatTrackList(grouplike) { + // Underlying function for flattenGrouplike. Can be used if you just want to + // get an array and not a grouplike, too. + + return grouplike.items.map(item => { + if (isGroup(item)) { + return getFlatTrackList(item) + } else { + return [item] + } + }).reduce((a, b) => a.concat(b), []) +} + +function getFlatGroupList(grouplike) { + // Analogue of getFlatTrackList for groups instead of tracks. Returns a flat + // array of all the groups in each level of the provided grouplike. + + return grouplike.items + .filter(isGroup) + .map(item => [item, ...getFlatGroupList(item)]) + .reduce((a, b) => a.concat(b), []) } function countTotalTracks(item) { @@ -687,12 +702,226 @@ function getCorrespondingPlayableForFile(item) { return parent.items.find(item => isPlayable(item) && path.basename(item.url, path.extname(item.url)) === basename) } +function getPathScore(path1, path2) { + // This function is basically only used in findTrackObject, but it's kinda + // huge and I need to test that it works outside of that context, so I'm + // sticking it on the global scope. Feel free to steal for whatever your + // weird future need for comparing any two paths is! + // + // path1 and path2 should be arrays of group names, according to the path + // you'd follow to open the groups and access a contained track. They should + // *not* include the track name, unless you want those to be considered a + // valid place for the paths to cross over! + // + // -- + // + // A path score is determined to be the number of groups which must be + // traversed across the two paths to find a matching group name and then + // reach the other track under that group. A lower score implies a closer + // match (since score increases not with "closeness" but "separation"). + // + // For example, these two paths are considered to have a score of zero + // against each other ("T" represents the track): + // + // X/B/C/T + // Y/B/C/T + // + // Their separation is zero because, starting from the closest (i.e. top) + // group to either the provided track or the reference data track, it takes + // zero additional steps to reach a group whose name is shared between the + // two paths: those top groups already have the same name. + // + // The above example indicates that the pattern before the closest matching + // path does not matter. Indeed, the actual length of the path could be + // different (W/X/B/C versus Y/B/C for example), and the score would still + // be the same. Parts of the path prepending the closest matching group + // name are thus ommitted from following examples. + // + // These paths, on the other hand, have a score of one: + // + // (...)/C/T + // (...)/C/D/T + // + // The closest matching name in this path is C. It is zero steps further + // from the start of the first path (C is the start); on the other path, + // it is one step further (D must be passed first). Therefore, the total + // steps that must be travelled to reach the start of one path to the + // start of the other by passing through the closest overlapping name is + // one: 0 + 1 = 1. + // + // In determining which of two paths are a closer match to a provided + // reference path, it's important to remember that a lower score (implying + // less separation) is better. Though we'll see the following example is + // probably more common across most music libraries, a reasonably natural + // example of the path structures above occurring in a music library could + // be this: an artist directory containing both albums and stray tracks, + // where one track apparently appears as both a stray track file and in an + // adjacent album directory; or, a mixtape which contains adjacent to its + // mixed-segment track listing a folder of the unmixed segments. + // + // These paths have a score of two: + // + // (...)/B/C/T + // (...)/B/D/T + // + // With the above examples, this one is fairly self explanatory. In this + // case, the closest matching group, B, is one step away from the start + // point (the first group before the track, i.e, the top name in the path) + // in both paths. Summed, the distance (and thus the score) is two. + // + // This example demonstrates what is probably a more realistic case of two + // tracks resembling each other (e.g. having the same name or source) but + // not sharing the same path: if B represents an artist, and C & D stand in + // place (in this example) of the names of that artist's albums, then it is + // reasonable to say the directories for the album are slightly different + // across the two paths. This could be the case for two users who ended up + // naming the album directory differently, or for one user restoring from + // their own backend/playlist after having adjusted the naming structure of + // their music library. It's also possible that there could simply be two + // albums by the same artist which contain a track of the same name; in + // that case, the path score implementation is doing exactly its job by + // indicating that these tracks would have a greater score (meaning further + // separation) than when checking against the track belonging to the same + // release. (If there is concern that such a track should not match at all + // because it may be a remarkably different track, other factors of + // resemblance -- position in album, duration, etc -- can be used to add + // detail to the apparent level of resemblance then.) + // + // -- + // + // A note on determining which name is the "closest" -- consider + // the following two paths: + // + // A/X/B/C/D/E/T + // A/Y/E/B/C/D/T + // + // There are many names which appear in both paths. So which do we treat + // as the closest? Well, what we're looking for is the shortest path across + // both paths, passing through at a particular name. To do this, we simply + // calculate the score for each name in the intersection of both paths + // (i.e. every name which shows up in both paths) using the same algorithm + // described above (sum of the distance from the start of either path). + // Then we take the lowest resultant score, and use that as the final score + // which is returned out of this function. + // + // TODO: There are probably optimizations to be made as far as avoiding + // processing every overlapping name goes (particularly once it's + // determined that no other path could be determined), but honestly + // I'm pretty sure if I tried to write an algorithm taking *that* + // into account, I'd end up screwing it up. :P So for now, we just + // do a simple filter and reduce operation. + // + // If the intersection of the two paths is empty (i.e. there is no overlap), + // we return the otherwise nonsense value, -1. + + const union = Array.from(new Set([...path1, ...path2])) + const intersection = union.filter( + name => path1.includes(name) && path2.includes(name)) + + if (!intersection.length) { + return -1 + } + + const reversed1 = path1.reverse() + const reversed2 = path2.reverse() + + const scores = intersection.map( + name => reversed1.indexOf(name) + reversed2.indexOf(name)) + + return scores.reduce((a, b) => a < b ? a : b) +} + +function findItemObject(referenceData, possibleChoices) { + // Finds the item object in the provided choices which most closely resembles + // the provided reference data. This is used for maintaining the identity of + // item objects when reloading a playlist (see serialized-backend.js). It's + // also usable in synchronizing the identity of items across linked clients + // (see socket.js). + + // Reference data includes item NAME, item SOURCE (downloaderArg), and item + // PATH (names of parent groups). Specifics of how existing item objects are + // determined to resemble this data are laid out next to the relevant + // implementation code. + // + // TODO: Should track number be considered here? + // TODO: Should track "metadata" (duration, md5?) be considered too? + // This in particular prompts questions of what the purpose of matching + // tracks *is*, and in considering those I lean towards "no" here, but + // it's probably worth looking at more in the future. (TM.) + + function getItemPathScore(item) { + if (!referenceData.path) { + return null + } + + const path1 = referenceData.path.slice() + const path2 = getItemPath(item).slice(0, -1).map(group => group.name) + return getPathScore(path1, path2) + } + + // The only items which will be considered at all are those which match at + // least one of the reference name/source. + const baselineResemble = possibleChoices.filter(item => + item.name === referenceData.name || + item.downloaderArg && item.downloaderArg === referenceData.downloaderArg) + + // If no item matches the baseline conditions for resemblance at all, + // return null. It's up to the caller to decide what to do in this case, + // e.g. reporting that no item was found, or creating a new item object + // from the reference data altogether. + if (!baselineResemble.length) { + return null + } + + // Find the "reasons" these items resemble the reference data; these will + // be used as the factors in calculating which item resembles closest. + const reasons = baselineResemble.map(item => ({ + item, + nameMatches: item.name === referenceData.name, + sourceMatches: item.downloaderArg && item.downloaderArg === referenceData.downloaderArg, + pathScore: getItemPathScore(item) + })) + + // TODO: The algorithm for determining which track matches closest is + // rudimentary at best right now. It would be well improved with + // better-detailed reasoning! That said, here's the current code + // laid out explicitly: + // + // Initial sort by matches is (NAME & SOURCE), SOURCE, NAME. Track which + // most closely matches path is returned thereafter, with ties broken by + // the initial sort. (If name, source, *and* path all are equal, first + // track as ordered in the source playlist/parent group is returned.) + // If no tracks have any path overlap, the first item in the sorted list + // is returned (since it is the closest match). (Again, ties here are + // broken according to source ordering.) + + reasons.sort((a, b) => + a.sourceMatches && !b.sourceMatches ? -1 : + !a.sourceMatches && b.sourceMatches ? 1 : + a.nameMatches && !b.nameMatches ? -1 : + !a.nameMatches && b.nameMatches ? 1 : + 0) + + let mostResembles + const sharePath = reasons.filter(({ pathScore }) => pathScore >= 0) + if (sharePath.length) { + mostResembles = sharePath.reduce((a, b) => a.pathScore < b.pathScore ? a : b) + } else { + mostResembles = reasons[0] + } + + return mostResembles.item +} + module.exports = { parentSymbol, updatePlaylistFormat, updateGroupFormat, updateTrackFormat, cloneGrouplike, filterTracks, - flattenGrouplike, countTotalTracks, + flattenGrouplike, + getFlatTrackList, + getFlatGroupList, + countTotalTracks, shuffleOrderOfGroups, reverseOrderOfGroups, partiallyFlattenGrouplike, collapseGrouplike, @@ -707,6 +936,38 @@ module.exports = { searchForItem, getCorrespondingFileForItem, getCorrespondingPlayableForFile, + getPathScore, + findItemObject, isGroup, isTrack, isOpenable, isPlayable } + +if (require.main === module) { + console.log(getPathScore(['A', 'B', 'C'], ['A', 'B', 'C'])) + console.log(getPathScore(['A', 'B', 'C'], ['A', 'B', 'C', 'D'])) + console.log(getPathScore(['A', 'B', 'C', 'E'], ['A', 'B', 'C'])) + console.log(getPathScore(['W', 'X'], ['Y', 'Z'])) + console.log(findItemObject( + // {name: 'T', downloaderArg: 'foo', path: ['A', 'B', 'C']}, + {name: 'B'}, + // getFlatTrackList( + getFlatGroupList( + updateGroupFormat({items: [ + {id: 1, name: 'T'}, + {id: 2, name: 'T'}, + {id: 3, name: 'T'}, + // {id: 4, name: 'T', downloaderArg: 'foo'}, + {id: 5, name: 'T'}, + {id: 6, name: 'Y', downloaderArg: 'foo'}, + {name: 'A', items: [ + {name: 'B', items: [ + {name: 'C', items: [ + {name: 'T'} + ]}, + {name: 'T'} + ]} + ]} + ]}) + ) + )) +} diff --git a/serialized-backend.js b/serialized-backend.js new file mode 100644 index 0000000..a3f02fa --- /dev/null +++ b/serialized-backend.js @@ -0,0 +1,245 @@ +// Tools for serializing a backend into a JSON-stringifiable object format, +// and for deserializing this format and loading its contained data into an +// existing backend instance. +// +// Serialized data includes the list of queue players and each player's state +// (queued items, playback position, etc). +// +// Serialized backend data can be used for a variety of purposes, such as +// writing the data to a file and saving it for later use, or transferring +// it over an internet connection to synchronize playback with a friend. +// (The code in socket.js exists to automate this process, as well as to +// provide a link so that changes to the queue or playback are synchronized +// in real-time.) +// +// TODO: Changes might be necessary all throughout the program to support +// having any number of objects refer to "the same track", as will likely be +// the case when restoring from a serialized backend. One way to handle this +// would be to (perhaps through the existing record store code) keep a handle +// on each of "the same track", which would be accessed by something like a +// serialized ID (ala symbols), or maybe just the track name / source URL. + +'use strict' + +const { + isGroup, + isTrack, + findItemObject, + flattenGrouplike, + getFlatGroupList, + getFlatTrackList, + getItemPath +} = require('./playlist-utils') + +const referenceDataSymbol = Symbol('Restored reference data') + +function getPlayerInfo(queuePlayer) { + const { player } = queuePlayer + return { + time: queuePlayer.time, + isLooping: player.isLooping, + isPaused: player.isPaused, + volume: player.volume + } +} + +function saveBackend(backend) { + return { + queuePlayers: backend.queuePlayers.map(QP => ({ + id: QP.id, + playingTrack: saveItemReference(QP.playingTrack), + queuedTracks: QP.queueGrouplike.items.map(saveItemReference), + pauseNextTrack: QP.pauseNextTrack, + playerInfo: getPlayerInfo(QP) + })) + } +} + +async function restoreBackend(backend, data) { + if (data.queuePlayers) { + if (data.queuePlayers.length === 0) { + return + } + + for (const qpData of data.queuePlayers) { + const QP = await backend.addQueuePlayer() + QP[referenceDataSymbol] = qpData + + QP.id = qpData.id + + QP.queueGrouplike.items = qpData.queuedTracks.map(refData => restoreNewItem(refData)) + + QP.player.setVolume(qpData.playerInfo.volume) + QP.player.setLoop(qpData.playerInfo.isLooping) + + QP.on('playing', () => { + QP[referenceDataSymbol].playingTrack = null + QP[referenceDataSymbol].playerInfo = null + }) + } + + // We remove the old queue players after the new ones have been added, + // because the backend won't let us ever have less than one queue player + // at a time. + while (backend.queuePlayers.length !== data.queuePlayers.length) { + backend.removeQueuePlayer(backend.queuePlayers[0]) + } + } +} + +async function restorePlayingTrack(queuePlayer, playedTrack, playerInfo) { + const QP = queuePlayer + await QP.stopPlaying() + QP.play(playedTrack, true) + QP.once('received time data', () => { + if (QP.playingTrack === playedTrack) { + QP.player.seekTo(playerInfo.time) + if (!playerInfo.isPaused) { + QP.player.togglePause() + } + } + }) +} + +function updateRestoredTracksUsingPlaylists(backend, playlists) { + // Utility function to restore the "identities" of tracks (i.e. which objects + // they are represented by) queued or playing in the provided backend, + // pulling possible track identities from the provided playlists. + // + // How well provided tracks resemble the ones existing in the backend (which + // have not already been replaced by an existing track) is calculated with + // the algorithm implemented in findItemObject, combining all provided + // playlists (simply putting them all in a group) to allow the algorithm to + // choose from all playlists equally at once. + // + // This function should be called after restoring a playlist and whenever + // a new source playlist is added (a new tab opened, etc). + // + // TODO: Though this helps to combat issues with restoring track identities + // when restoring from a saved backend, it could be expanded to restore from + // closed sources as well (reference data would have to be automatically + // saved on the tracks independently of save/restore in order to support + // this sort of functionality). Note this would still face difficulties with + // opening two identical playlists (i.e. the same playlist twice), since then + // identities would be equally correctly picked from either source; this is + // an inevitable issue with the way identities are resolved, but could be + // lessened in the UI by simply opening a new view (rather than a whole new + // load, with new track identities) when a playlist is opened twice at once. + + const possibleChoices = getFlatTrackList({items: playlists}) + + for (const QP of backend.queuePlayers) { + let playingDataToRestore + + const qpData = (QP[referenceDataSymbol] || {}) + const waitingTrackData = qpData.playingTrack + if (waitingTrackData) { + playingDataToRestore = waitingTrackData + } else if (QP.playingTrack) { + playingDataToRestore = QP.playingTrack[referenceDataSymbol] + } + + if (playingDataToRestore) { + const found = findItemObject(playingDataToRestore, possibleChoices) + if (found) { + restorePlayingTrack(QP, found, qpData.playerInfo || getPlayerInfo(QP)) + } + } + + QP.queueGrouplike.items = QP.queueGrouplike.items.map(track => { + const refData = track[referenceDataSymbol] + if (!refData) { + return track + } + + return findItemObject(refData, possibleChoices) || track + }) + + QP.emit('queue updated') + } +} + +function saveItemReference(item) { + // Utility function to generate reference data for a track or grouplike, + // according to the format taken by findItemObject. + + if (isTrack(item)) { + return { + name: item.name, + path: getItemPath(item).slice(0, -1).map(group => group.name), + downloaderArg: item.downloaderArg + } + } else if (isGroup(item)) { + return { + name: item.name, + path: getItemPath(item).slice(0, -1).map(group => group.name), + items: item.items.map(saveItemReference) + } + } else if (item) { + return item + } else { + return null + } +} + +function restoreNewItem(referenceData, playlists) { + // Utility function to restore a new item. If you're restoring tracks + // already present in a backend, use the specific function for that, + // updateRestoredTracksUsingPlaylists. + // + // This function takes a playlists array like the function for restoring + // tracks in a backend, but in this function, it's optional: if not provided, + // it will simply skip searching for a resembling track and return a new + // track object right away. + + let found + if (playlists) { + let possibleChoices + if (referenceData.downloaderArg) { + possibleChoices = getFlatTrackList({items: playlists}) + } else if (referenceData.items) { + possibleChoices = getFlatGroupList({items: playlists}) + } + if (possibleChoices) { + found = findItemObject(referenceData, possibleChoices) + } + } + + if (found) { + return found + } else if (referenceData.downloaderArg) { + return { + [referenceDataSymbol]: referenceData, + name: referenceData.name, + downloaderArg: referenceData.downloaderArg + } + } else if (referenceData.items) { + return { + [referenceDataSymbol]: referenceData, + name: referenceData.name, + items: referenceData.items.map(item => restoreNewItem(item, playlists)) + } + } else { + return { + [referenceDataSymbol]: referenceData, + name: referenceData.name + } + } +} + +function getWaitingTrackData(queuePlayer) { + // Utility function to get reference data for the track which is currently + // waiting to be played, once a resembling track is found. This should only + // be used to reflect that data in the user interface. + + return (queuePlayer[referenceDataSymbol] || {}).playingTrack +} + +Object.assign(module.exports, { + saveBackend, + restoreBackend, + updateRestoredTracksUsingPlaylists, + saveItemReference, + restoreNewItem, + getWaitingTrackData +}) diff --git a/socket.js b/socket.js new file mode 100644 index 0000000..fec66d7 --- /dev/null +++ b/socket.js @@ -0,0 +1,720 @@ +// Tools for hosting an MTUI party over a socket server. Comparable in idea to +// telnet.js, but for interfacing over commands rather than hosting all client +// UIs on one server. The intent of the code in this file is to allow clients +// to connect and interface with each other, while still running all processes +// involved in mtui on their own machines -- so mtui will download and play +// music using each connected machine's own internet connection and speakers. + +// TODO: Option to display listing items which aren't available on all +// connected devices. + +'use strict' // single quotes & no semicolons time babey + +// This is expected to be the same across both the client and the server. +// There will probably be inconsistencies between sender clients and receiving +// clients / the server otherwise. +const DEFAULT_NICKNAME = '(Unnamed)' + +const EventEmitter = require('events') +const net = require('net') + +const { + saveBackend, + restoreBackend, + saveItemReference, + restoreNewItem, + updateRestoredTracksUsingPlaylists +} = require('./serialized-backend') + +const { + getTimeStringsFromSec, + silenceEvents +} = require('./general-util') + +function serializeCommandToData(command) { + // Turn a command into a string/buffer that can be sent over a socket. + return JSON.stringify(command) +} + +function deserializeDataToCommand(data) { + // Turn data received from a socket into a command that can be processed as + // an action to apply to the mtui backend. + return JSON.parse(data) +} + +function isItemRef(ref) { + if (ref === null || typeof ref !== 'object') { + return false + } + + // List of true/false/null. False means *invalid* reference data; null + // means *nonpresent* reference data. True means present and valid. + const conditionChecks = [ + 'name' in ref ? typeof ref.name === 'string' : null, + 'path' in ref ? Array.isArray(ref.path) && ref.path.every(n => typeof n === 'string') : null, + 'downloaderArg' in ref ? ( + !('items' in ref) && + typeof ref.downloaderArg === 'string' + ) : null, + 'items' in ref ? ( + !('downloaderArg' in ref) && + Array.isArray(ref.items) && + ref.items.every(isItemRef) + ) : null + ] + + if (conditionChecks.includes(false)) { + return false + } + + if (!conditionChecks.includes(true)) { + return false + } + + return true +} + +function validateCommand(command) { + // TODO: Could be used to validate "against" a backend, but for now it just + // checks data types. + + if (typeof command !== 'object') { + return false + } + + if (!['server', 'client'].includes(command.sender)) { + return false + } + + switch (command.sender) { + case 'server': + switch (command.code) { + case 'initialize-backend': + return typeof command.backend === 'object' + } + // No break here; servers can send commands which typically come from + // clients too. + case 'client': + switch (command.code) { + case 'clear-queue': + return typeof command.queuePlayer === 'string' + case 'clear-queue-past': + case 'clear-queue-up-to': + return ( + typeof command.queuePlayer === 'string' && + isItemRef(command.track) + ) + case 'distribute-queue': + return ( + typeof command.queuePlayer === 'string' && + isItemRef(command.topItem) && + (!command.opts || typeof command.opts === 'object' && ( + ( + !command.opts.how || + ['evenly', 'randomly'].includes(command.opts.how) + ) && + ( + !command.opts.rangeEnd || + ['end-of-queue'].includes(command.opts.rangeEnd) || + typeof command.opts.rangeEnd === 'number' + ) + )) + ) + case 'play': + return ( + typeof command.queuePlayer === 'string' && + isItemRef(command.track) + ) + case 'queue': + return ( + typeof command.queuePlayer === 'string' && + isItemRef(command.topItem) && + ( + isItemRef(command.afterItem) || + [null, 'FRONT'].includes(command.afterItem) + ) && + (!command.opts || typeof command.opts === 'object' && ( + ( + !command.opts.movePlayingTrack || + typeof command.opts.movePlayingTrack === 'boolean' + ) + )) + ) + case 'restore-queue': + return ( + typeof command.queuePlayer === 'string' && + Array.isArray(command.tracks) && + command.tracks.every(track => isItemRef(track)) && + ['shuffle'].includes(command.why) + ) + case 'seek-to': + return ( + typeof command.queuePlayer === 'string' && + typeof command.time === 'number' + ) + case 'set-nickname': + return ( + typeof command.nickname === 'string' && + typeof command.oldNickname === 'string' && + command.nickname.length >= 1 && + command.nickname.length <= 12 + ) + case 'set-pause': + return ( + typeof command.queuePlayer === 'string' && + typeof command.paused === 'boolean' && + ( + typeof command.startingTrack === 'boolean' && + command.sender === 'server' + ) || !command.startingTrack + ) + case 'status': + return ( + command.status === 'done-playing' || + ( + command.status === 'ready-to-resume' && + typeof command.queuePlayer === 'string' + ) || + command.status === 'sync-playback' + ) + case 'stop-playing': + return typeof command.queuePlayer === 'string' + case 'unqueue': + return ( + typeof command.queuePlayer === 'string' && + isItemRef(command.topItem) + ) + } + break + } + + return false +} + +function perLine(handleLine) { + // Wrapper function to run a callback for each line provided to the wrapped + // callback. Maintains a "partial" variable so that a line may be broken up + // into multiple chunks before it is sent. Also supports handling multiple + // lines (including the conclusion to a previously received partial line) + // being received at once. + + let partial = '' + return data => { + const text = data.toString() + const lines = text.split('\n') + if (lines.length === 1) { + partial += text + } else { + handleLine(partial + lines[0]) + for (const line of lines.slice(1, -1)) { + handleLine(line) + } + partial = lines[lines.length - 1] + } + } +} + +function makeSocketServer() { + // The socket server has two functions: to maintain a "canonical" backend + // and synchronize newly connected clients with the relevent data in this + // backend, and to receive command data from clients and relay this to + // other clients. + // + // makeSocketServer doesn't actually start the server listening on a port; + // that's the responsibility of the caller (use server.listen()). + + const server = new net.Server() + const sockets = [] + + server.canonicalBackend = null + + // <variable> -> queue player id -> array: socket + const readyToResume = {} + const donePlaying = {} + + server.on('connection', socket => { + sockets.push(socket) + + let nickname = DEFAULT_NICKNAME + + socket.on('close', () => { + if (sockets.includes(socket)) { + sockets.splice(sockets.indexOf(socket), 1) + } + }) + + socket.on('data', perLine(line => { + // Parse data as a command and validate it. If invalid, drop this data. + + let command + try { + command = deserializeDataToCommand(line) + } catch (error) { + return + } + + command.sender = 'client' + command.senderNickname = nickname + + if (!validateCommand(command)) { + return + } + + // If it's a status command, respond appropriately, and return so that it + // is not relayed. + + if (command.code === 'status') { + switch (command.status) { + case 'done-playing': { + const doneSockets = donePlaying[command.queuePlayer] + if (doneSockets && !doneSockets.includes(socket)) { + doneSockets.push(socket) + if (doneSockets.length === sockets.length) { + // determine next track + for (const socket of sockets) { + // play next track + } + delete donePlaying[command.queuePlayer] + } + } + } + case 'ready-to-resume': { + const readySockets = readyToResume[command.queuePlayer] + if (readySockets && !readySockets.includes(socket)) { + readySockets.push(socket) + if (readySockets.length === sockets.length) { + for (const socket of sockets) { + socket.write(serializeCommandToData({ + sender: 'server', + code: 'set-pause', + queuePlayer: command.queuePlayer, + startingTrack: true, + paused: false + }) + '\n') + donePlaying[command.queuePlayer] = [] + } + delete readyToResume[command.queuePlayer] + } + } + break + } + case 'sync-playback': + for (const QP of server.canonicalBackend.queuePlayers) { + if (QP.timeData) { + socket.write(serializeCommandToData({ + sender: 'server', + code: 'seek-to', + queuePlayer: QP.id, + time: QP.timeData.curSecTotal + }) + '\n') + socket.write(serializeCommandToData({ + sender: 'server', + code: 'set-pause', + queuePlayer: QP.id, + startingTrack: true, + paused: QP.player.isPaused + }) + '\n') + } + } + break + } + return + } + + // If it's a 'play' command, set up a new readyToResume array. + + if (command.code === 'play') { + readyToResume[command.queuePlayer] = [] + } + + // If it's a 'set-nickname' command, save the nickname. + // Also attach the old nickname for display in log messages. + + if (command.code === 'set-nickname') { + command.oldNickname = nickname + command.senderNickname = nickname + nickname = command.nickname + } + + // Relay the command to client sockets besides the sender. + + const otherSockets = sockets.filter(s => s !== socket) + + for (const socket of otherSockets) { + socket.write(serializeCommandToData(command) + '\n') + } + })) + + const savedBackend = saveBackend(server.canonicalBackend) + + for (const qpData of savedBackend.queuePlayers) { + if (qpData.playerInfo) { + qpData.playerInfo.isPaused = true + } + } + + socket.write(serializeCommandToData({ + sender: 'server', + code: 'initialize-backend', + backend: savedBackend + }) + '\n') + }) + + return server +} + +function makeSocketClient() { + // The socket client connects to a server and sends/receives commands to/from + // that server. This doesn't actually connect the socket to a port/host; that + // is the caller's responsibility (use client.socket.connect()). + + const client = new EventEmitter() + client.socket = new net.Socket() + client.nickname = DEFAULT_NICKNAME + + client.sendCommand = function(command) { + const data = serializeCommandToData(command) + client.socket.write(data + '\n') + client.emit('sent-command', command) + } + + client.setNickname = function(nickname) { + let oldNickname = client.nickname + client.nickname = nickname + client.sendCommand({code: 'set-nickname', nickname, oldNickname}) + } + + client.socket.on('data', perLine(line => { + // Same sort of "guarding" deserialization/validation as in the server + // code, because it's possible the client and server backends mismatch. + + let command + try { + command = deserializeDataToCommand(line) + } catch (error) { + return + } + + if (!validateCommand(command)) { + return + } + + client.emit('command', command) + })) + + return client +} + +function attachBackendToSocketClient(backend, client, { + getPlaylistSources +}) { + // All actual logic for instances of the mtui backend interacting with each + // other through commands lives here. + + backend.setAlwaysStartPaused(true) + backend.setWaitWhenDonePlaying(true) + + function logCommand(command) { + const nickToMessage = nickname => `\x1b[32;1m${nickname}\x1b[0m` + const itemToMessage = item => `\x1b[32m"${item.name}"\x1b[0m` + let fullmsg = '' // may be overridden + let actionmsg = `sent ${command.code}` // fallback + switch (command.code) { + case 'clear-queue': + actionmsg = 'cleared the queue' + break + case 'clear-queue-past': + actionmsg = `cleared the queue past ${itemToMessage(command.track)}` + break + case 'clear-queue-up-to': + actionmsg = `cleared the queue up to ${itemToMessage(command.track)}` + break + case 'distribute-queue': + actionmsg = `distributed ${itemToMessage(command.topItem)} across the queue ${command.opts.how}` + break + case 'initialize-backend': + return + case 'play': + actionmsg = `started playing ${itemToMessage(command.track)}` + break + case 'queue': { + let afterMessage = '' + if (isItemRef(command.afterItem)) { + afterMessage = ` after ${itemToMessage(command.afterItem)}` + } else if (command.afterItem === 'FRONT') { + afterMessage = ` at the front of the queue` + } + actionmsg = `queued ${itemToMessage(command.topItem)}` + afterMessage + break + } + case 'restore-queue': + if (command.why === 'shuffle') { + actionmsg = 'shuffled the queue' + } + break + case 'seek-to': + // TODO: the second value here should be the duration of the track + // (this will make values like 0:0x:yy / 1:xx:yy appear correctly) + actionmsg = `seeked to ${getTimeStringsFromSec(command.time, command.time).timeDone}` + break + case 'set-nickname': + fullmsg = `${nickToMessage(command.nickname)} updated their nickname (from ${nickToMessage(command.oldNickname)})` + break + case 'set-pause': + if (command.paused) { + actionmsg = 'paused the player' + } else { + actionmsg = 'resumed the player' + } + break + case 'stop-playing': + actionmsg = 'stopped the player' + break + case 'unqueue': + actionmsg = `removed ${itemToMessage(command.topItem)} from the queue` + break + case 'status': + return + } + if (!fullmsg) { + const nickname = command.sender === 'server' ? 'the server' : command.senderNickname + fullmsg = `${nickToMessage(nickname)} ${actionmsg}` + } + backend.showLogMessage(fullmsg) + } + + client.on('sent-command', command => { + command.senderNickname = client.nickname + logCommand(command) + }) + + client.on('command', async command => { + logCommand(command) + switch (command.sender) { + case 'server': + switch (command.code) { + case 'initialize-backend': + await restoreBackend(backend, command.backend) + // TODO: does this need to be called here? + updateRestoredTracksUsingPlaylists(backend, getPlaylistSources()) + backend.on('playing', QP => { + QP.once('received time data', () => { + client.sendCommand({code: 'status', status: 'sync-playback'}) + }) + }) + return + } + // Again, no pause. Client commands can come from the server. + case 'client': { + let QP = ( + command.queuePlayer && + backend.queuePlayers.find(QP => QP.id === command.queuePlayer) + ) + + switch (command.code) { + case 'clear-queue': + if (QP) silenceEvents(QP, ['clear-queue'], () => QP.clearQueue()) + return + case 'clear-queue-past': + if (QP) silenceEvents(QP, ['clear-queue-past'], () => QP.clearQueuePast( + restoreNewItem(command.track, getPlaylistSources()) + )) + return + case 'clear-queue-up-to': + if (QP) silenceEvents(QP, ['clear-queue-up-to'], () => QP.clearQueueUpTo( + restoreNewItem(command.track, getPlaylistSources()) + )) + return + case 'distribute-queue': + if (QP) silenceEvents(QP, ['distribute-queue'], () => QP.distributeQueue( + restoreNewItem(command.topItem), + { + how: command.opts.how, + rangeEnd: command.opts.rangeEnd + } + )) + return + case 'play': + if (QP) { + QP.once('received time data', data => { + client.sendCommand({ + code: 'status', + status: 'ready-to-resume', + queuePlayer: QP.id + }) + }) + silenceEvents(QP, ['playing'], () => QP.play( + restoreNewItem(command.track, getPlaylistSources()) + )) + } + return + case 'queue': + if (QP) silenceEvents(QP, ['queue'], () => QP.queue( + restoreNewItem(command.topItem), + isItemRef(command.afterItem) ? restoreNewItem(command.afterItem, getPlaylistSources()) : command.afterItem, + { + movePlayingTrack: command.opts.movePlayingTrack + } + )) + return + case 'restore-queue': + if (QP) { + QP.replaceAllItems(command.tracks.map( + refData => restoreNewItem(refData, getPlaylistSources()) + )) + } + return + case 'seek-to': + if (QP) silenceEvents(QP, ['seek-to'], () => QP.seekTo(command.time)) + return + case 'set-pause': { + // TODO: there's an event leak here when toggling pause while + // nothing is playing + let playingThisTrack = true + QP.once('playing new track', () => { + playingThisTrack = false + }) + setTimeout(() => { + if (playingThisTrack) { + if (QP) silenceEvents(QP, ['set-pause'], () => QP.setPause(command.paused)) + } + }, command.startingTrack ? 500 : 0) + return + } + case 'stop-playing': + if (QP) silenceEvents(QP, ['playing'], () => QP.stopPlaying()) + return + case 'unqueue': + if (QP) silenceEvents(QP, ['unqueue'], () => QP.unqueue( + restoreNewItem(command.topItem, getPlaylistSources()) + )) + return + } + } + } + }) + + backend.on('clear-queue', queuePlayer => { + client.sendCommand({ + code: 'clear-queue', + queuePlayer: queuePlayer.id + }) + }) + + backend.on('clear-queue-past', (queuePlayer, track) => { + client.sendCommand({ + code: 'clear-queue-past', + queuePlayer: queuePlayer.id, + track: saveItemReference(track) + }) + }) + + backend.on('clear-queue-up-to', (queuePlayer, track) => { + client.sendCommand({ + code: 'clear-queue-up-to', + queuePlayer: queuePlayer.id, + track: saveItemReference(track) + }) + }) + + backend.on('distribute-queue', (queuePlayer, topItem, opts) => { + client.sendCommand({ + code: 'distribute-queue', + queuePlayer: queuePlayer.id, + topItem: saveItemReference(topItem), + opts + }) + }) + + backend.on('done playing', queuePlayer => { + client.sendCommand({ + code: 'status', + status: 'done-playing', + queuePlayer: queuePlayer.id + }) + }) + + backend.on('playing', (queuePlayer, track) => { + if (track) { + client.sendCommand({ + code: 'play', + queuePlayer: queuePlayer.id, + track: saveItemReference(track) + }) + queuePlayer.once('received time data', data => { + client.sendCommand({ + code: 'status', + status: 'ready-to-resume', + queuePlayer: queuePlayer.id + }) + }) + } else { + client.sendCommand({ + code: 'stop-playing', + queuePlayer: queuePlayer.id + }) + } + }) + + backend.on('queue', (queuePlayer, topItem, afterItem, opts) => { + client.sendCommand({ + code: 'queue', + queuePlayer: queuePlayer.id, + topItem: saveItemReference(topItem), + afterItem: saveItemReference(afterItem), + opts + }) + }) + + function handleSeek(queuePlayer) { + client.sendCommand({ + code: 'seek-to', + queuePlayer: queuePlayer.id, + time: queuePlayer.time + }) + } + + backend.on('seek-ahead', handleSeek) + backend.on('seek-back', handleSeek) + backend.on('seek-to', handleSeek) + + backend.on('shuffle-queue', queuePlayer => { + client.sendCommand({ + code: 'restore-queue', + why: 'shuffle', + queuePlayer: queuePlayer.id, + tracks: queuePlayer.queueGrouplike.items.map(saveItemReference) + }) + }) + + backend.on('toggle-pause', queuePlayer => { + client.sendCommand({ + code: 'set-pause', + queuePlayer: queuePlayer.id, + paused: queuePlayer.player.isPaused + }) + }) + + backend.on('unqueue', (queuePlayer, topItem) => { + client.sendCommand({ + code: 'unqueue', + queuePlayer: queuePlayer.id, + topItem: saveItemReference(topItem) + }) + }) +} + +function attachSocketServerToBackend(server, backend) { + // Unlike the function for attaching a backend to follow commands from a + // client (attachBackendToSocketClient), this function is minimalistic. + // It just sets the associated "canonical" backend. Actual logic for + // de/serialization lives in serialized-backend.js. + server.canonicalBackend = backend +} + +Object.assign(module.exports, { + makeSocketServer, + makeSocketClient, + attachBackendToSocketClient, + attachSocketServerToBackend +}) diff --git a/ui.js b/ui.js index de73a81..68cda91 100644 --- a/ui.js +++ b/ui.js @@ -32,6 +32,11 @@ const { } = require('./playlist-utils') const { + updateRestoredTracksUsingPlaylists, + getWaitingTrackData +} = require('./serialized-backend') + +const { ui: { Dialog, DisplayElement, @@ -54,7 +59,7 @@ const { } = require('tui-lib') /* text editor features disabled because theyre very much incomplete and havent - * gotten much use from me or anyonea afaik! + * gotten much use from me or anyone afaik! const TuiTextEditor = require('tui-text-editor') */ @@ -191,6 +196,8 @@ class AppElement extends FocusElement { this.isPartyHost = false this.enableAutoDJ = false + this.playlistSources = [] + this.config = Object.assign({ canControlPlayback: true, canControlQueue: true, @@ -238,6 +245,18 @@ class AppElement extends FocusElement { }) */ + this.logPane = new Pane() + this.addChild(this.logPane) + + this.log = new Log() + this.logPane.addChild(this.log) + this.logPane.visible = false + + this.log.on('log-message', () => { + this.logPane.visible = true + this.fixLayout() + }) + if (!this.config.showTabberPane) { this.tabberPane.visible = false } @@ -430,12 +449,13 @@ class AppElement extends FocusElement { bindListeners() { for (const key of [ - 'handlePlaying', + 'handlePlayingDetails', 'handleReceivedTimeData', 'handleProcessMetadataProgress', 'handleQueueUpdated', 'handleAddedQueuePlayer', - 'handleRemovedQueuePlayer' + 'handleRemovedQueuePlayer', + 'handleLogMessage' ]) { this[key] = this[key].bind(this) } @@ -468,7 +488,7 @@ class AppElement extends FocusElement { PIE.on('toggle pause', () => PIE.queuePlayer.togglePause()) queuePlayer.on('received time data', this.handleReceivedTimeData) - queuePlayer.on('playing', this.handlePlaying) + queuePlayer.on('playing details', this.handlePlayingDetails) queuePlayer.on('queue updated', this.handleQueueUpdated) } @@ -497,7 +517,7 @@ class AppElement extends FocusElement { } queuePlayer.removeListener('receivedTimeData', this.handleReceivedTimeData) - queuePlayer.removeListener('playing', this.handlePlaying) + queuePlayer.removeListener('playing details', this.handlePlayingDetails) queuePlayer.removeListener('queue updated', this.handleQueueUpdated) queuePlayer.stopPlaying() } @@ -506,12 +526,14 @@ class AppElement extends FocusElement { this.backend.on('processMetadata progress', this.handleProcessMetadataProgress) this.backend.on('added queue player', this.handleAddedQueuePlayer) this.backend.on('removed queue player', this.handleRemovedQueuePlayer) + this.backend.on('log message', this.handleLogMessage) } removeBackendListeners() { this.backend.removeListener('processMetadata progress', this.handleProcessMetadataProgress) this.backend.removeListener('added queue player', this.handleAddedQueuePlayer) this.backend.removeListener('removed queue player', this.handleRemovedQueuePlayer) + this.backend.removeListener('log message', this.handleLogMessage) } handleAddedQueuePlayer(queuePlayer) { @@ -525,7 +547,11 @@ class AppElement extends FocusElement { } } - async handlePlaying(track, oldTrack, queuePlayer) { + handleLogMessage(text) { + this.log.newLogMessage(text) + } + + async handlePlayingDetails(track, oldTrack, queuePlayer) { const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer) if (PIE) { PIE.updateTrack() @@ -1209,6 +1235,9 @@ class AppElement extends FocusElement { grouplike = await processSmartPlaylist(grouplike) + this.playlistSources.push(grouplike) + updateRestoredTracksUsingPlaylists(this.backend, this.playlistSources) + if (!this.tabber.currentElement || newTab && this.tabber.currentElement.grouplike) { const grouplikeListing = this.newGrouplikeListing() grouplikeListing.loadGrouplike(grouplike) @@ -1322,10 +1351,21 @@ class AppElement extends FocusElement { } */ + if (this.logPane.visible) { + this.logPane.w = leftWidth + this.logPane.h = 6 + this.log.fillParent() + this.log.fixAllLayout() + } + if (this.tabberPane.visible) { this.tabberPane.w = leftWidth this.tabberPane.y = bottomY this.tabberPane.h = topY - this.tabberPane.y + if (this.logPane.visible) { + this.tabberPane.h -= this.logPane.h + this.logPane.y = this.tabberPane.bottom + } /* if (this.textInfoPane.visible) { this.tabberPane.h -= this.textInfoPane.h @@ -3517,6 +3557,7 @@ class PlaybackInfoElement extends FocusElement { refreshTrackText(maxNameWidth = Infinity) { const { playingTrack } = this.queuePlayer + const waitingTrackData = getWaitingTrackData(this.queuePlayer) if (playingTrack) { this.currentTrack = playingTrack const { name } = playingTrack @@ -3528,6 +3569,11 @@ class PlaybackInfoElement extends FocusElement { this.progressBarLabel.text = '' this.progressTextLabel.text = '(Starting..)' this.timeData = {} + } else if (waitingTrackData) { + const { name } = waitingTrackData + this.clearInfoText() + this.trackNameLabel.text = name + this.progressTextLabel.text = '(Waiting to play, once found in playlist source.)' } else { this.clearInfoText() } @@ -4540,4 +4586,68 @@ class NotesTextEditor extends TuiTextEditor { } */ +class Log extends ListScrollForm { + constructor() { + super('vertical') + } + + newLogMessage(text) { + if (this.inputs.length === 10) { + this.removeInput(this.inputs[0]) + } + + const logMessage = new LogMessage(text) + this.addInput(logMessage) + this.fixLayout() + this.scrollToEnd() + this.emit('log-message', logMessage) + return logMessage + } +} + +class LogMessage extends FocusElement { + constructor(text) { + super() + + this.label = new LogMessageLabel(text) + this.addChild(this.label) + } + + fixLayout() { + this.w = this.parent.contentW + this.label.w = this.contentW + this.h = this.label.h + } + + clicked(button) { + if (button === 'left') { + this.root.select(this) + return false + } + } +} + +class LogMessageLabel extends WrapLabel { + writeTextTo(writable) { + const w = this.w + const lines = this.getWrappedLines() + for (let i = 0; i < lines.length; i++) { + const text = this.processFormatting(lines[i]) + writable.write(ansi.moveCursor(this.absTop + i, this.absLeft)) + writable.write(text) + const width = ansi.measureColumns(text) + if (width < w && this.textAttributes.length) { + writable.write(ansi.setAttributes([ansi.A_RESET, ...this.textAttributes])) + writable.write(' '.repeat(w - width)) + } + } + } + + set textAttributes(val) {} + + get textAttributes() { + return this.parent.isSelected ? [40] : [] + } +} + module.exports = AppElement |