« 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.js157
1 files changed, 111 insertions, 46 deletions
diff --git a/players.js b/players.js
index dde1fbf..7b11a3b 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,50 @@ 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)
   }
 }
 
-module.exports.getPlayer = async function(name = null, options = []) {
+export async function getPlayer(name = null, 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
   }