« get me outta code hell

mtui - Music Text User Interface - user-friendly command line music player
about summary refs log tree commit diff
path: root/players.js
diff options
context:
space:
mode:
Diffstat (limited to 'players.js')
-rw-r--r--players.js364
1 files changed, 354 insertions, 10 deletions
diff --git a/players.js b/players.js
index 959bf27..b3d7315 100644
--- a/players.js
+++ b/players.js
@@ -255,20 +255,15 @@ export class ControllableMPVPlayer extends MPVPlayer {
   }
 
   setPause(val) {
-    const wasPaused = this.isPaused
-    this.isPaused = !!val
-
-    if (this.isPaused !== wasPaused) {
-      this.sendCommand('cycle', 'pause')
+    if (!!val !== this.isPaused) {
+      this.togglePause()
     }
-
-    // For some reason "set pause" doesn't seem to be working anymore:
-    // this.sendCommand('set', 'pause', this.isPaused)
   }
 
   setLoop(val) {
-    this.isLooping = !!val
-    this.sendCommand('set', 'loop', this.isLooping)
+    if (!!val !== this.isLooping) {
+      this.toggleLoop()
+    }
   }
 
   async kill() {
@@ -383,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') {