diff options
Diffstat (limited to 'players.js')
-rw-r--r-- | players.js | 506 |
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 } |