From 7f2461a60fba35013551fdb27ba0bb8d0720021d Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 14 May 2023 19:53:31 -0300 Subject: new GhostPlayer class & support 👻 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This also makes the UI not explode when duration strings aren't available for progress bar display, and shows getTimeStringsFromSec how to handle that. --- backend.js | 13 +++ general-util.js | 97 +++++++++------- players.js | 349 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ todo.txt | 3 + ui.js | 9 +- 5 files changed, 428 insertions(+), 43 deletions(-) diff --git a/backend.js b/backend.js index 6576868..232d912 100644 --- a/backend.js +++ b/backend.js @@ -660,6 +660,12 @@ class QueuePlayer extends EventEmitter { this.emit('set loop queue at end', !!value) } + setDuration(duration) { + if (this.player.setDuration) { + setTimeout(() => this.player.setDuration(duration)) + } + } + get remainingTracks() { const index = this.queueGrouplike.items.indexOf(this.playingTrack) const length = this.queueGrouplike.items.length @@ -776,6 +782,13 @@ export default class Backend extends EventEmitter { }) } + queuePlayer.on('playing', track => { + if (track) { + const metadata = this.getMetadataFor(track) + queuePlayer.setDuration(metadata.duration) + } + }) + return queuePlayer } diff --git a/general-util.js b/general-util.js index 536b3fd..d369848 100644 --- a/general-util.js +++ b/general-util.js @@ -147,62 +147,77 @@ export function getSecFromTimestamp(timestamp) { } } -export function getTimeStringsFromSec(curSecTotal, lenSecTotal, fraction = false) { - const percentVal = (100 / lenSecTotal) * curSecTotal - const percentDone = ( - (Math.trunc(percentVal * 100) / 100).toFixed(2) + '%' - ) - - const leftSecTotal = lenSecTotal - curSecTotal - let leftHour = Math.floor(leftSecTotal / 3600) - let leftMin = Math.floor((leftSecTotal - leftHour * 3600) / 60) - let leftSec = Math.floor(leftSecTotal - leftHour * 3600 - leftMin * 60) - let leftFrac = lenSecTotal % 1 - - // Yeah, yeah, duplicate math. +export function getTimeStringsFromSec(curSecTotal, lenSecTotal = null, fraction = false) { + const pad = val => val.toString().padStart(2, '0') + const padFrac = val => Math.floor(val * 1000).toString().padEnd(3, '0') + + // We don't want to display hour counters if the total length is less + // than an hour. + const displayAsHours = Math.max(curSecTotal, lenSecTotal ?? 0) >= 3600 + + const strings = {curSecTotal, lenSecTotal} + let curHour = Math.floor(curSecTotal / 3600) let curMin = Math.floor((curSecTotal - curHour * 3600) / 60) let curSec = Math.floor(curSecTotal - curHour * 3600 - curMin * 60) let curFrac = curSecTotal % 1 - // Wee! - let lenHour = Math.floor(lenSecTotal / 3600) - let lenMin = Math.floor((lenSecTotal - lenHour * 3600) / 60) - let lenSec = Math.floor(lenSecTotal - lenHour * 3600 - lenMin * 60) - let lenFrac = lenSecTotal % 1 - - const pad = val => val.toString().padStart(2, '0') - const padFrac = val => Math.floor(val * 1000).toString().padEnd(3, '0') curMin = pad(curMin) curSec = pad(curSec) - lenMin = pad(lenMin) - lenSec = pad(lenSec) - leftMin = pad(leftMin) - leftSec = pad(leftSec) curFrac = padFrac(curFrac) - lenFrac = padFrac(lenFrac) - leftFrac = padFrac(leftFrac) - // We don't want to display hour counters if the total length is less - // than an hour. - let timeDone, timeLeft, duration - if (parseInt(lenHour) > 0 || parseInt(curHour) > 0) { - timeDone = `${curHour}:${curMin}:${curSec}` - timeLeft = `${leftHour}:${leftMin}:${leftSec}` - duration = `${lenHour}:${lenMin}:${lenSec}` + if (displayAsHours) { + strings.timeDone = `${curHour}:${curMin}:${curSec}` } else { - timeDone = `${curMin}:${curSec}` - timeLeft = `${leftMin}:${leftSec}` - duration = `${lenMin}:${lenSec}` + strings.timeDone = `${curMin}:${curSec}` } if (fraction) { - timeDone += '.' + curFrac - timeLeft += '.' + leftFrac - duration += '.' + lenFrac + strings.timeDone += '.' + curFrac + } + + if (typeof lenSecTotal === 'number') { + const percentVal = (100 / lenSecTotal) * curSecTotal + strings.percentDone = (Math.trunc(percentVal * 100) / 100).toFixed(2) + '%' + + // Yeah, yeah, duplicate math. + const leftSecTotal = lenSecTotal - curSecTotal + let leftHour = Math.floor(leftSecTotal / 3600) + let leftMin = Math.floor((leftSecTotal - leftHour * 3600) / 60) + let leftSec = Math.floor(leftSecTotal - leftHour * 3600 - leftMin * 60) + let leftFrac = leftSecTotal % 1 + + // Wee! + let lenHour = Math.floor(lenSecTotal / 3600) + let lenMin = Math.floor((lenSecTotal - lenHour * 3600) / 60) + let lenSec = Math.floor(lenSecTotal - lenHour * 3600 - lenMin * 60) + let lenFrac = lenSecTotal % 1 + + lenMin = pad(lenMin) + lenSec = pad(lenSec) + lenFrac = padFrac(lenFrac) + + leftMin = pad(leftMin) + leftSec = pad(leftSec) + leftFrac = padFrac(leftFrac) + + if (typeof lenSecTotal === 'number') { + if (displayAsHours) { + strings.timeLeft = `${leftHour}:${leftMin}:${leftSec}` + strings.duration = `${lenHour}:${lenMin}:${lenSec}` + } else { + strings.timeLeft = `${leftMin}:${leftSec}` + strings.duration = `${lenMin}:${lenSec}` + } + + if (fraction) { + strings.timeLeft += '.' + leftFrac + strings.duration += '.' + lenFrac + } + } } - return {percentDone, timeDone, timeLeft, duration, curSecTotal, lenSecTotal} + return strings } export function getTimeStrings({curHour, curMin, curSec, lenHour, lenMin, lenSec}) { diff --git a/players.js b/players.js index 7b11a3b..b3d7315 100644 --- a/players.js +++ b/players.js @@ -378,7 +378,356 @@ export class SoXPlayer extends Player { } } +export class GhostPlayer extends Player { + // The music player which makes believe! This player doesn't actually process + // any files nor interface with an underlying binary or API to provide real + // sound playback. It just provides all the usual interfaces as best as it + // can - simulating playback time by accounting for pause/resume, seeking, + // and so on, for example. + + statusInterval = 250 + + // This is always a number if a track is "loaded", whether or not paused. + // It's null if no track is loaded (aka "stopped"). It's used as the base + // for the playback time, if resumed, or directly as the current playback + // time, if paused. (Note: time is internally tracked in milliseconds.) + #playingFrom = null + + // This is null if no track is "loaded" (aka "stopped") or if paused. + // It's used to calculate the current playback time when resumed. + #resumedSince = null + + // These are interval/timer identifiers and are null if no track is loaded + // or if paused. + #statusInterval = null + #doneTimeout = null + #loopTimeout = null + + // This is a callback which resolves the playFile promise. It exists at the + // same time as playingFrom, i.e. while a track is "loaded", whether or not + // paused. + #resolvePlayFilePromise = null + + // This is reset to null every time a track is started. It can be provided + // externally with setDuration(). It's used to control emitting a "done" + // event. + #duration = null + + setDuration(duration) { + // This is a unique interface on GhostPlayer, not found on other players. + // Most players inherently know when to resolve playFile thanks to the + // child process exiting (or outputting a message) when the input file is + // done. GhostPlayer is intended not to operate on actual files at all, so + // we couldn't even read duration metadata if we wanted to. So, this extra + // interface can be used to provide that data instead! + + if (this.#playingFrom === null) { + return + } + + if (duration !== null) { + if (this.#getPlaybackTime() >= duration * 1000) { + // No need to do anything else if we're already done playing according + // to the provided duration. + this.#donePlaying() + return + } + } + + this.#affectTimeRemaining(() => { + this.#duration = duration + }) + } + + playFile(file, startTime = 0) { + // This function is public, and might be called without any advance notice, + // so clear existing playback info. This also resolves a prior playFile + // promise. + if (this.#playingFrom !== null) { + this.#donePlaying() + } + + const promise = new Promise(resolve => { + this.#resolvePlayFilePromise = resolve + }) + + this.#playingFrom = 1000 * startTime + + // It's possible to have paused the player before the next track came up, + // in which case playback begins paused. + if (!this.isPaused) { + this.#resumedSince = Date.now() + } + + this.#status() + this.#startStatusInterval() + + // We can't start any end-of-track timeouts here because we don't have a + // duration yet - we'll instate the appropriate timeout once it's been + // provided externally (with setDuration()). + + return promise + } + + setPause(paused) { + if (!paused && this.isPaused) { + this.#resumedSince = Date.now() + + this.#status() + this.#startStatusInterval() + + if (this.#duration !== null) { + if (this.isLooping) { + this.#startLoopTimeout() + } else { + this.#startDoneTimeout() + } + } + } + + if (paused && !this.isPaused) { + this.#playingFrom = this.#getPlaybackTime() + this.#resumedSince = null + + this.#status() + this.#clearStatusInterval() + + if (this.#duration !== null) { + if (this.isLooping) { + this.#clearLoopTimeout() + } else { + this.#clearDoneTimeout() + } + } + } + + this.isPaused = paused + } + + togglePause() { + this.setPause(!this.isPaused) + } + + setLoop(looping) { + if (!looping && this.isLooping) { + if (this.#duration !== null) { + this.#clearLoopTimeout() + this.#startDoneTimeout() + } + } + + if (looping && !this.isLooping) { + if (this.#duration !== null) { + this.#clearDoneTimeout() + this.#startLoopTimeout() + } + } + + this.isLooping = looping + } + + toggleLoop() { + this.setLoop(!this.isLooping) + } + + seekToStart() { + if (this.#playingFrom === null) { + return + } + + this.seekTo(0) + } + + seekAhead(secs) { + if (this.#playingFrom === null) { + return + } + + this.seekTo(this.#getPlaybackTime() / 1000 + secs) + } + + seekBack(secs) { + if (this.#playingFrom === null) { + return + } + + this.seekTo(this.#getPlaybackTime() / 1000 - secs) + } + + seekTo(timeInSecs) { + if (this.#playingFrom === null) { + return + } + + let seekTime = null + + if (this.#duration !== null && timeInSecs > this.#duration) { + // Seeking past the duration of the track either loops it or ends it. + if (this.isLooping) { + seekTime = 0 + } else { + this.#donePlaying() + return + } + } else if (timeInSecs < 0) { + // You can't seek before the beginning of a track! + seekTime = 0 + } else { + // Otherwise, just seek to the specified time. + seekTime = timeInSecs + } + + this.#affectTimeRemaining(() => { + if (this.#resumedSince !== null) { + // Seeking doesn't pause, but it does functionally reset where we're + // measuring playback time from. + this.#resumedSince = Date.now() + } + + this.#playingFrom = seekTime * 1000 + }) + } + + async kill() { + if (this.#playingFrom === null) { + return + } + + this.#donePlaying() + } + + #affectTimeRemaining(callback) { + // Changing the time remaining (i.e. the delta between current playback + // time and duration) means any timeouts which run when the track ends + // need to be reset with the new delta. This function also handily creates + // those timeouts in the first place if a duration hadn't been set before. + + if (this.#resumedSince !== null && this.#duration !== null) { + // If there was an existing timeout for the end of the track, clear it. + // We're going to instate a new one in a moment. + if (this.isLooping) { + this.#clearLoopTimeout() + } else { + this.#clearDoneTimeout() + } + } + + // Do something which will affect the time remaining. + callback() + + this.#status() + + if (this.#resumedSince !== null && this.#duration !== null) { + // Start a timeout for the (possibly new) end of the track, but only if + // we're actually playing! + if (this.isLooping) { + this.#startLoopTimeout() + } else { + this.#startDoneTimeout() + } + } + } + + #startStatusInterval() { + if (this.#statusInterval !== null) { + throw new Error(`Status interval already set (this code shouldn't be reachable!)`) + } + + this.#statusInterval = setInterval(() => this.#status(), this.statusInterval) + } + + #startDoneTimeout() { + if (this.#doneTimeout !== null) { + throw new Error(`Done timeout already set (this code shouldn't be reachable!)`) + } + + const timeoutInMilliseconds = this.#duration * 1000 - this.#getPlaybackTime() + this.#doneTimeout = setTimeout(() => this.#donePlaying(), timeoutInMilliseconds) + } + + #startLoopTimeout() { + if (this.#loopTimeout !== null) { + throw new Error(`Loop timeout already set (this code shouldn't be reachable!)`) + } + + const timeoutInMilliseconds = this.#duration * 1000 - this.#getPlaybackTime() + this.#loopTimeout = setTimeout(() => this.#loopAtEnd(), timeoutInMilliseconds) + } + + #clearStatusInterval() { + if (this.#statusInterval === null) { + throw new Error(`Status interval not set yet (this code shouldn't be reachable!)`) + } + + clearInterval(this.#statusInterval) + this.#statusInterval = null + } + + #clearDoneTimeout() { + if (this.#doneTimeout === null) { + throw new Error(`Done timeout not set yet (this code shouldn't be reachable!)`) + } + + clearTimeout(this.#doneTimeout) + this.#doneTimeout = null + } + + #clearLoopTimeout() { + if (this.#loopTimeout === null) { + throw new Error(`Loop timeout nout set yet (this code shouldn't be reachable!)`) + } + + clearTimeout(this.#loopTimeout) + this.#loopTimeout = null + } + + #status() { + // getTimeStringsFromSec supports null duration, so we don't need to + // perform a specific check here. + const timeInSecs = this.#getPlaybackTime() / 1000 + this.printStatusLine(getTimeStringsFromSec(timeInSecs, this.#duration)) + } + + #donePlaying() { + if (this.#resumedSince !== null) { + this.#clearStatusInterval() + } + + // Run this first, while we still have a track "loaded". This ensures the + // end-of-track timeouts get cleared appropriately (if they've been set). + this.setDuration(null) + + this.#playingFrom = null + this.#resumedSince = null + + // No, this doesn't have any spooky tick order errors - resolved promises + // always continue on a later tick of the event loop, not the current one. + // So the second line here will always happen before any potential future + // calls to playFile(). + this.#resolvePlayFilePromise() + this.#resolvePlayFilePromise = null + } + + #loopAtEnd() { + // Looping is just seeking back to the start! This will also cause the + // loop timer to be reinstated (via #affectTimeRemaining). + this.seekToStart() + } + + #getPlaybackTime() { + if (this.#resumedSince === null) { + return this.#playingFrom + } else { + return this.#playingFrom + Date.now() - this.#resumedSince + } + } +} + export async function getPlayer(name = null, options = []) { + if (name === 'ghost') { + return new GhostPlayer(options) + } + if (await commandExists('mpv') && (name === null || name === 'mpv')) { return new ControllableMPVPlayer(options) } else if (name === 'mpv') { diff --git a/todo.txt b/todo.txt index d1cb2cc..59a9f87 100644 --- a/todo.txt +++ b/todo.txt @@ -738,6 +738,9 @@ TODO: GHOST BACKEND for socket server... the main thing is syncing duration data. It sucks to have the player, like, actually be tied to a specific instance of MPV or whatever, so we'd use a ~ghost player~ which supports all the usual interfaces and lies about its current playback time. Yay! + (Partway: The ghost player exists now, and the backend and UI handle it! + Just need to hook up a "dummy" backend for the server, with ghost player + and duration metadata received from socket clients.) TODO: There should be a way for the server to handle disputes between two clients disagreeing on the duration of a track. Options could include, diff --git a/ui.js b/ui.js index 4a92ebe..6cabb32 100644 --- a/ui.js +++ b/ui.js @@ -4439,9 +4439,14 @@ class PlaybackInfoElement extends FocusElement { this.isLooping = player.isLooping this.isPaused = player.isPaused - this.progressBarLabel.text = '-'.repeat(Math.floor(this.w / lenSecTotal * curSecTotal)) + if (duration) { + this.progressBarLabel.text = '-'.repeat(Math.floor(this.w / lenSecTotal * curSecTotal)) + this.progressTextLabel.text = timeDone + ' / ' + duration + } else { + this.progressBarLabel.text = '' + this.progressTextLabel.text = timeDone + } - this.progressTextLabel.text = timeDone + ' / ' + duration if (player.isLooping) { this.progressTextLabel.text += ' [Looping]' } -- cgit 1.3.0-6-gf8a5