« 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.js506
1 files changed, 460 insertions, 46 deletions
diff --git a/players.js b/players.js
index dde1fbf..b3d7315 100644
--- a/players.js
+++ b/players.js
@@ -1,15 +1,22 @@
 // stolen from http-music
 
-const { spawn } = require('child_process')
-const { commandExists, killProcess, getTimeStrings } = require('./general-util')
-const EventEmitter = require('events')
-const Socat = require('./socat')
-const fs = require('fs')
-const util = require('util')
-
-const unlink = util.promisify(fs.unlink)
-
-class Player extends EventEmitter {
+import {
+  commandExists,
+  killProcess,
+  getTimeStrings,
+  getTimeStringsFromSec,
+} from './general-util.js'
+
+import {spawn} from 'node:child_process'
+import {statSync} from 'node:fs'
+import {unlink} from 'node:fs/promises'
+import EventEmitter from 'node:events'
+import path from 'node:path'
+import url from 'node:url'
+
+import Socat from './socat.js'
+
+export class Player extends EventEmitter {
   constructor(processOptions = []) {
     super()
 
@@ -37,13 +44,14 @@ class Player extends EventEmitter {
     return this._process
   }
 
-  playFile(file) {}
-  seekAhead(secs) {}
-  seekBack(secs) {}
-  seekTo(timeInSecs) {}
-  volUp(amount) {}
-  volDown(amount) {}
-  setVolume(value) {}
+  playFile(_file, _startTime) {}
+  seekAhead(_secs) {}
+  seekBack(_secs) {}
+  seekTo(_timeInSecs) {}
+  seekToStart() {}
+  volUp(_amount) {}
+  volDown(_amount) {}
+  setVolume(_value) {}
   updateVolume() {}
   togglePause() {}
   toggleLoop() {}
@@ -86,24 +94,44 @@ class Player extends EventEmitter {
   }
 }
 
-module.exports.MPVPlayer = class extends Player {
-  getMPVOptions(file) {
-    const opts = ['--no-video', file]
+export class MPVPlayer extends Player {
+  // The more powerful MPV player. MPV is virtually impossible for a human
+  // being to install; if you're having trouble with it, try the SoX player.
+
+  getMPVOptions(file, startTime) {
+    const opts = [
+      `--term-status-msg='${this.getMPVStatusMessage()}'`,
+      '--no-video',
+      file
+    ]
+
     if (this.isLooping) {
       opts.unshift('--loop')
     }
+
     if (this.isPaused) {
       opts.unshift('--pause')
     }
+
+    if (startTime) {
+      opts.unshift('--start=' + startTime)
+    }
+
     opts.unshift('--volume=' + this.volume * this.volumeMultiplier)
+
     return opts
   }
 
-  playFile(file) {
-    // The more powerful MPV player. MPV is virtually impossible for a human
-    // being to install; if you're having trouble with it, try the SoX player.
+  getMPVStatusMessage() {
+    // Note: This function shouldn't include any single-quotes! It probably
+    // (NOTE: PROBABLY) wouldn't cause any security issues, but it will break
+    // --term-status-msg parsing and might keep mpv from starting at all.
+
+    return '${=time-pos} ${=duration} ${=percent-pos}'
+  }
 
-    this.process = spawn('mpv', this.getMPVOptions(file).concat(this.processOptions))
+  playFile(file, startTime) {
+    this.process = spawn('mpv', this.getMPVOptions(file, startTime).concat(this.processOptions))
 
     let lastPercent = 0
 
@@ -113,14 +141,14 @@ module.exports.MPVPlayer = class extends Player {
       }
 
       const match = data.toString().match(
-        /(..):(..):(..) \/ (..):(..):(..) \(([0-9]+)%\)/
+        /([0-9.]+) ([0-9.]+) ([0-9.]+)/
       )
 
       if (match) {
         const [
-          curHour, curMin, curSec, // ##:##:##
-          lenHour, lenMin, lenSec, // ##:##:##
-          percent // ###%
+          curSecTotal,
+          lenSecTotal,
+          percent
         ] = match.slice(1)
 
         if (parseInt(percent) < lastPercent) {
@@ -133,7 +161,7 @@ module.exports.MPVPlayer = class extends Player {
 
         lastPercent = parseInt(percent)
 
-        this.printStatusLine(getTimeStrings({curHour, curMin, curSec, lenHour, lenMin, lenSec}))
+        this.printStatusLine(getTimeStringsFromSec(curSecTotal, lenSecTotal))
       }
 
       this.updateVolume();
@@ -145,22 +173,21 @@ module.exports.MPVPlayer = class extends Player {
   }
 }
 
-module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer {
-  getMPVOptions(file) {
-    return ['--input-ipc-server=' + this.socat.path, ...super.getMPVOptions(file)]
+export class ControllableMPVPlayer extends MPVPlayer {
+  getMPVOptions(...args) {
+    return ['--input-ipc-server=' + this.socat.path, ...super.getMPVOptions(...args)]
   }
 
-  playFile(file) {
+  playFile(file, startTime) {
     this.removeSocket(this.socketPath)
 
     do {
-      // this.socketPathpath = '/tmp/mtui-socket-' + Math.floor(Math.random() * 10000)
-      this.socketPath = __dirname + '/mtui-socket-' + Math.floor(Math.random() * 10000)
+      this.socketPath = path.join(path.dirname(url.fileURLToPath(import.meta.url)), 'mtui-socket-' + Math.floor(Math.random() * 10000))
     } while (this.existsSync(this.socketPath))
 
     this.socat = new Socat(this.socketPath)
 
-    const mpv = super.playFile(file)
+    const mpv = super.playFile(file, startTime)
 
     mpv.then(() => this.removeSocket(this.socketPath))
 
@@ -169,7 +196,7 @@ module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer {
 
   existsSync(path) {
     try {
-      fs.statSync(path)
+      statSync(path)
       return true
     } catch (error) {
       return false
@@ -194,6 +221,10 @@ module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer {
     this.sendCommand('seek', timeInSecs, 'absolute')
   }
 
+  seekToStart() {
+    this.seekTo(0)
+  }
+
   volUp(amount) {
     this.setVolume(this.volume + amount)
   }
@@ -253,14 +284,19 @@ module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer {
   }
 }
 
-module.exports.SoXPlayer = class extends Player {
-  playFile(file) {
+export class SoXPlayer extends Player {
+  playFile(file, startTime) {
     // SoX's play command is useful for systems that don't have MPV. SoX is
     // much easier to install (and probably more commonly installed, as well).
     // You don't get keyboard controls such as seeking or volume adjusting
     // with SoX, though.
 
-    this.process = spawn('play', [file].concat(this.processOptions))
+    this._file = file
+
+    this.process = spawn('play', [file].concat(
+      this.processOptions,
+      startTime ? ['trim', startTime] : []
+    ))
 
     this.process.stdout.on('data', data => {
       process.stdout.write(data.toString())
@@ -274,14 +310,12 @@ module.exports.SoXPlayer = class extends Player {
           return
         }
 
-        const timeRegex = '([0-9]*):([0-9]*):([0-9]*)\.([0-9]*)'
+        const timeRegex = String.raw`([0-9]*):([0-9]*):([0-9]*)\.([0-9]*)`
         const match = data.toString().trim().match(new RegExp(
           `^In:([0-9.]+%)\\s*${timeRegex}\\s*\\[${timeRegex}\\]`
         ))
 
         if (match) {
-          const percentStr = match[1]
-
           // SoX takes a loooooot of math in order to actually figure out the
           // duration, since it outputs the current time and the remaining time
           // (but not the duration).
@@ -309,19 +343,399 @@ module.exports.SoXPlayer = class extends Player {
 
     return new Promise(resolve => {
       this.process.on('close', () => resolve())
+    }).then(() => {
+      if (this._restartPromise) {
+        const p = this._restartPromise
+        this._restartPromise = null
+        return p
+      }
+    })
+  }
+
+  async seekToStart() {
+    // SoX doesn't support a command interface to interact while playback is
+    // ongoing. However, we can simulate seeking to the start by restarting
+    // playback altogether. We just need to be careful not to resolve the
+    // original playback promise before the new one is complete!
+
+    if (!this._file) {
+      return
+    }
+
+    let resolve = null
+    let reject = null
+
+    // The original call of playFile() will yield control to this promise, which
+    // we bind to the resolve/reject of a new call to playFile().
+    this._restartPromise = new Promise((res, rej) => {
+      resolve = res
+      reject = rej
+    })
+
+    await this.kill()
+
+    this.playFile(this._file).then(resolve, reject)
+  }
+}
+
+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
+    }
+  }
 }
 
-module.exports.getPlayer = async function(name = null, options = []) {
+export async function getPlayer(name = null, options = []) {
+  if (name === 'ghost') {
+    return new GhostPlayer(options)
+  }
+
   if (await commandExists('mpv') && (name === null || name === 'mpv')) {
-    return new module.exports.ControllableMPVPlayer(options)
+    return new ControllableMPVPlayer(options)
   } else if (name === 'mpv') {
     return null
   }
 
   if (await commandExists('play') && (name === null || name === 'sox')) {
-    return new module.exports.SoXPlayer(options)
+    return new SoXPlayer(options)
   } else if (name === 'sox') {
     return null
   }