« get me outta code hell

new GhostPlayer class & support 👻 - mtui - Music Text User Interface - user-friendly command line music player
about summary refs log tree commit diff
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-05-14 19:53:31 -0300
committer(quasar) nebula <qznebula@protonmail.com>2023-05-14 19:53:31 -0300
commit7f2461a60fba35013551fdb27ba0bb8d0720021d (patch)
treebcaaf43e2d54ffeff7a400c3a1b32480e118c8eb
parent56ad0bab6cbcc079887d5a5086cb116844bc351e (diff)
new GhostPlayer class & support 👻
This also makes the UI not explode when duration strings aren't
available for progress bar display, and shows getTimeStringsFromSec
how to handle that.
-rw-r--r--backend.js13
-rw-r--r--general-util.js97
-rw-r--r--players.js349
-rw-r--r--todo.txt3
-rw-r--r--ui.js9
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]'
     }