diff options
-rw-r--r-- | backend.js | 7 | ||||
-rwxr-xr-x | index.js | 31 | ||||
-rw-r--r-- | serialized-backend.js | 180 | ||||
-rw-r--r-- | socket.js | 176 | ||||
-rw-r--r-- | ui.js | 16 |
5 files changed, 407 insertions, 3 deletions
diff --git a/backend.js b/backend.js index 4142026..048a6c5 100644 --- a/backend.js +++ b/backend.js @@ -63,6 +63,7 @@ class QueuePlayer extends EventEmitter { this.queueGrouplike = {name: 'Queue', isTheQueue: true, items: []} this.pauseNextTrack = false this.queueEndMode = 'end' // end, loop, shuffle + this.playedTrackToEnd = false this.timeData = null @@ -353,7 +354,7 @@ class QueuePlayer extends EventEmitter { } - async play(item, startTime = 0) { + async play(item, startTime = 0, forceStartPaused = false) { if (this.player === null) { throw new Error('Attempted to play before a player was loaded') } @@ -403,7 +404,9 @@ class QueuePlayer extends EventEmitter { this.emit('playing', this.playingTrack, oldTrack, startTime, this) await this.player.kill() - if (this.playedTrackToEnd) { + if (forceStartPaused) { + this.player.setPause(true) + } else if (this.playedTrackToEnd) { this.player.setPause(this.pauseNextTrack) this.pauseNextTrack = false this.playedTrackToEnd = false diff --git a/index.js b/index.js index 7632844..0553808 100755 --- a/index.js +++ b/index.js @@ -4,11 +4,22 @@ import {getPlayer} from './players.js' import {parseOptions} from './general-util.js' -import {getItemPathString} from './playlist-utils.js' import Backend from './backend.js' import setupClient from './client.js' import TelnetServer from './telnet.js' +import { + attachBackendToSocketClient, + attachSocketServerToBackend, + makeSocketServer, + makeSocketClient, +} from './socket.js' + +import { + getItemPathString, + updatePlaylistFormat, +} from './playlist-utils.js' + import {CommandLineInterface} from 'tui-lib/util/interfaces' import * as ansi from 'tui-lib/util/ansi' @@ -52,6 +63,8 @@ async function main() { 'player-options': {type: 'series'}, 'stress-test': {type: 'flag'}, + 'socket-client': {type: 'value'}, + 'socket-server': {type: 'flag'}, 'telnet-server': {type: 'flag'}, 'skip-config-file': {type: 'flag'}, 'config-file': {type: 'value'}, @@ -164,6 +177,22 @@ async function main() { appElement.attachAsServerHost(telnetServer) } + let socketServer + if (options['socket-server']) { + socketServer = makeSocketServer() + attachSocketServerToBackend(socketServer, backend) + socketServer.listen(1255) + } + + let socketClient + if (options['socket-client']) { + socketClient = makeSocketClient() + attachBackendToSocketClient(backend, socketClient, { + getPlaylistSources: () => appElement.playlistSources + }) + socketClient.socket.connect(1255) + } + if (options['stress-test']) { await loadPlaylistPromise diff --git a/serialized-backend.js b/serialized-backend.js new file mode 100644 index 0000000..4ea3019 --- /dev/null +++ b/serialized-backend.js @@ -0,0 +1,180 @@ +// 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' + +import { + findTrackObject, + flattenGrouplike, + getItemPath, +} from './playlist-utils.js' + +const referenceDataSymbol = Symbol('Restored reference data') + +function getPlayerInfo(queuePlayer) { + const { player, timeData } = queuePlayer + return { + time: timeData && timeData.curSecTotal, + isLooping: player.isLooping, + isPaused: player.isPaused, + volume: player.volume + } +} + +export function saveBackend(backend) { + function referenceTrack(track) { + if (track) { + // This is the same format used as referenceData in findTrackObject + // (in playlist-utils.js). + return { + name: track.name, + downloaderArg: track.downloaderArg, + path: getItemPath(track).slice(0, -1).map(group => group.name) + } + } else { + return null + } + } + + return { + queuePlayers: backend.queuePlayers.map(QP => ({ + playingTrack: referenceTrack(QP.playingTrack), + queuedTracks: QP.queueGrouplike.items.map(referenceTrack), + pauseNextTrack: QP.pauseNextTrack, + playerInfo: getPlayerInfo(QP) + })) + } +} + +export 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.queueGrouplike.items = qpData.queuedTracks.map(refData => ({ + [referenceDataSymbol]: refData, + name: refData.name, + downloaderArg: refData.downloaderArg + })) + + 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() + } + } + }) +} + +export function updateRestoredTracksUsingPlaylists(backend, playlists) { + // Utility function to restore the "identities" of tracks (i.e. which objects + // they are represented by) queued or playing in the provided backend, + // pulling possible track identities from the provided playlists. + // + // 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 findTrackObject, 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 combinedPlaylist = {items: playlists} + const flattenedPlaylist = flattenGrouplike(combinedPlaylist) + + 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 = findTrackObject(playingDataToRestore, combinedPlaylist, flattenedPlaylist) + 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 findTrackObject(refData, combinedPlaylist, flattenedPlaylist) || track + }) + + QP.emit('queue updated') + } +} + +export function getWaitingTrackData(queuePlayer) { + // Utility function to get reference data for the track which is currently + // waiting to be played, once a resembling track is found. This should only + // be used to reflect that data in the user interface. + + return (queuePlayer[referenceDataSymbol] || {}).playingTrack +} diff --git a/socket.js b/socket.js new file mode 100644 index 0000000..0e8759b --- /dev/null +++ b/socket.js @@ -0,0 +1,176 @@ +// 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 + +import EventEmitter from 'node:events' +import * as net from 'node:net' + +import { + restoreBackend, + saveBackend, + updateRestoredTracksUsingPlaylists, +} from './serialized-backend.js' + +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 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' + } + break + case 'client': + break + } + + return false +} + +export function makeSocketServer() { + // The socket server has two functions: to maintain a "canonical" backend + // and synchronize newly connected clients with the relevent data in this + // backend, and to receive command data from clients and relay this to + // 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 + + function receivedData(data) { + // Parse data as a command and validate it. If invalid, drop this data. + + let command + try { + command = deserializeDataToCommand(data) + } catch (error) { + return + } + + if (!validateCommand(command)) { + return + } + + // Relay the data to client sockets. + + for (const socket of sockets) { + socket.write(command) + } + } + + server.on('connection', socket => { + sockets.push(socket) + + socket.on('close', () => { + if (sockets.includes(socket)) { + sockets.splice(sockets.indexOf(socket), 1) + } + }) + + socket.on('data', receivedData) + + socket.write(JSON.stringify({ + sender: 'server', + code: 'initialize-backend', + backend: saveBackend(server.canonicalBackend) + })) + }) + + return server +} + +export function makeSocketClient() { + // The socket client connects to a server and sends/receives commands to/from + // that server. This doesn't actually connect the socket to a port/host; that + // is the caller's responsibility (use client.socket.connect()). + + const client = new EventEmitter() + client.socket = new net.Socket() + + client.sendCommand = function(command) { + const data = serializeCommandToData(command) + client.socket.write(data) + } + + client.socket.on('data', data => { + // 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(data) + } catch (error) { + return + } + + if (!validateCommand(command)) { + return + } + + client.emit('command', command) + }) + + return client +} + +export function attachBackendToSocketClient(backend, client, { + getPlaylistSources +}) { + // All actual logic for instances of the mtui backend interacting with each + // other through commands lives here. + + client.on('command', async 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()) + break + } + break + } + }) +} + +export function attachSocketServerToBackend(server, backend) { + // Unlike the function for attaching a backend to follow commands from a + // client (attachBackendToSocketClient), this function is minimalistic. + // It just sets the associated "canonical" backend. Actual logic for + // de/serialization lives in serialized-backend.js. + server.canonicalBackend = backend +} diff --git a/ui.js b/ui.js index b784688..dd1123b 100644 --- a/ui.js +++ b/ui.js @@ -49,6 +49,11 @@ import { shuffleOrderOfGroups, } from './playlist-utils.js' +import { + updateRestoredTracksUsingPlaylists, + getWaitingTrackData +} from './serialized-backend.js' + /* text editor features disabled because theyre very much incomplete and havent * gotten much use from me or anyonea afaik! const TuiTextEditor = require('tui-text-editor') @@ -179,6 +184,8 @@ export default class AppElement extends FocusElement { this.isPartyHost = false this.enableAutoDJ = false + this.playlistSources = [] + this.config = Object.assign({ canControlPlayback: true, canControlQueue: true, @@ -1588,6 +1595,9 @@ export default 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) @@ -4430,6 +4440,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 @@ -4441,6 +4452,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() } |