« get me outta code hell

timestamp files!!! - 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 <towerofnix@gmail.com>2021-07-13 23:14:20 -0300
committer(quasar) nebula <towerofnix@gmail.com>2021-07-13 23:14:20 -0300
commit382d5afc7e2ac24f67b7c891328b8b9bb7e91058 (patch)
tree87d97b4e20e48d936b33ef2ced898227af29a2e0
parent80577b066352fe2dfecd706302e183a5705c193b (diff)
timestamp files!!!
-rw-r--r--backend.js17
-rw-r--r--general-util.js10
-rw-r--r--players.js26
-rw-r--r--playlist-utils.js2
-rw-r--r--todo.txt16
-rw-r--r--ui.js286
6 files changed, 341 insertions, 16 deletions
diff --git a/backend.js b/backend.js
index ad13127..048aec5 100644
--- a/backend.js
+++ b/backend.js
@@ -368,7 +368,7 @@ class QueuePlayer extends EventEmitter {
   }
 
 
-  async play(item) {
+  async play(item, startTime) {
     if (this.player === null) {
       throw new Error('Attempted to play before a player was loaded')
     }
@@ -425,7 +425,7 @@ class QueuePlayer extends EventEmitter {
       } else {
         this.player.setPause(false)
       }
-      await this.player.playFile(downloadFile)
+      await this.player.playFile(downloadFile, startTime)
     }
 
     // playingThisTrack now means whether the track played through to the end
@@ -510,6 +510,15 @@ class QueuePlayer extends EventEmitter {
     return false
   }
 
+  async playOrSeek(item, time) {
+    if (item === this.playingTrack) {
+      this.seekTo(time)
+    } else {
+      this.queue(item, this.playingTrack)
+      this.play(item, time)
+    }
+  }
+
   clearPlayingTrack() {
     if (this.playingTrack !== null) {
       const oldTrack = this.playingTrack
@@ -531,6 +540,10 @@ class QueuePlayer extends EventEmitter {
     this.player.seekBack(seconds)
   }
 
+  seekTo(seconds) {
+    this.player.seekTo(seconds)
+  }
+
   togglePause() {
     this.player.togglePause()
   }
diff --git a/general-util.js b/general-util.js
index 0a81cdc..f63ae21 100644
--- a/general-util.js
+++ b/general-util.js
@@ -139,6 +139,16 @@ module.exports.throttlePromise = function(maximumAtOneTime = 10) {
   return enqueue
 }
 
+module.exports.getSecFromTimestamp = function(timestamp) {
+  const parts = timestamp.split(':').map(n => parseInt(n))
+  switch (parts.length) {
+    case 3: return parts[0] * 3600 + parts[1] * 60 + parts[2]
+    case 2: return parts[0] * 60 + parts[1]
+    case 1: return parts[0]
+    default: return 0
+  }
+}
+
 module.exports.getTimeStringsFromSec = function(curSecTotal, lenSecTotal) {
   const percentVal = (100 / lenSecTotal) * curSecTotal
   const percentDone = (
diff --git a/players.js b/players.js
index e22e505..b41ce0c 100644
--- a/players.js
+++ b/players.js
@@ -37,7 +37,7 @@ class Player extends EventEmitter {
     return this._process
   }
 
-  playFile(file) {}
+  playFile(file, startTime) {}
   seekAhead(secs) {}
   seekBack(secs) {}
   seekTo(timeInSecs) {}
@@ -87,7 +87,7 @@ class Player extends EventEmitter {
 }
 
 module.exports.MPVPlayer = class extends Player {
-  getMPVOptions(file) {
+  getMPVOptions(file, startTime) {
     const opts = ['--no-video', file]
     if (this.isLooping) {
       opts.unshift('--loop')
@@ -95,15 +95,18 @@ module.exports.MPVPlayer = class extends Player {
     if (this.isPaused) {
       opts.unshift('--pause')
     }
+    if (startTime) {
+      opts.unshift('--start=' + startTime)
+    }
     opts.unshift('--volume=' + this.volume * this.volumeMultiplier)
     return opts
   }
 
-  playFile(file) {
+  playFile(file, startTime) {
     // 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.
 
-    this.process = spawn('mpv', this.getMPVOptions(file).concat(this.processOptions))
+    this.process = spawn('mpv', this.getMPVOptions(file, startTime).concat(this.processOptions))
 
     let lastPercent = 0
 
@@ -146,11 +149,11 @@ 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)]
+  getMPVOptions(...args) {
+    return ['--input-ipc-server=' + this.socat.path, ...super.getMPVOptions(...args)]
   }
 
-  playFile(file) {
+  playFile(file, startTime) {
     this.removeSocket(this.socketPath)
 
     do {
@@ -160,7 +163,7 @@ module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer {
 
     this.socat = new Socat(this.socketPath)
 
-    const mpv = super.playFile(file)
+    const mpv = super.playFile(file, startTime)
 
     mpv.then(() => this.removeSocket(this.socketPath))
 
@@ -252,13 +255,16 @@ module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer {
 }
 
 module.exports.SoXPlayer = class extends Player {
-  playFile(file) {
+  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.process = spawn('play', [file].concat(
+      this.processOptions,
+      startTime ? ['trim', startTime] : []
+    ))
 
     this.process.stdout.on('data', data => {
       process.stdout.write(data.toString())
diff --git a/playlist-utils.js b/playlist-utils.js
index 1015748..227c985 100644
--- a/playlist-utils.js
+++ b/playlist-utils.js
@@ -653,7 +653,7 @@ function getCorrespondingFileForItem(item, extension) {
     return null
   }
 
-  const checkExtension = item => item.url && path.extname(item.url) === extension
+  const checkExtension = item => item.url && item.url.endsWith(extension)
 
   if (isPlayable(item)) {
     const parent = item[parentSymbol]
diff --git a/todo.txt b/todo.txt
index ed7c830..e7a2e31 100644
--- a/todo.txt
+++ b/todo.txt
@@ -579,3 +579,19 @@ TODO: "Challenge 1 (Tricks)" etc in FP World 3 are "Challenge (Tricks)"! Bad.
 
 TODO: Pressing next track (shift+N) on the last track should start the first
       track, if the queue is being looped.
+
+TODO: Timestamp files. Oh heck yes.
+      (Done!)
+
+TODO: Show the current chunk of a track you're on according to its timestamps,
+      in both the queue and the main listing! (Put the playing indicator next
+      to both the track itself and the timestamp element.)
+
+      Possibly tricky, but try to make this tie in with the "time since/until"
+      indicator thingies at the bottom of the queue listing element too!
+
+TODO: Some kind of timestamp indicator in the progress bar area??? E.g, name
+      of the current timestamp, and MAYBE some kind of visual breakup of the
+      progress bar itself?
+
+TODO: Timestamp editing within mtui itself?????????
diff --git a/ui.js b/ui.js
index a167dab..75dfe41 100644
--- a/ui.js
+++ b/ui.js
@@ -8,6 +8,7 @@ const UndoManager = require('./undo-manager')
 
 const {
   commandExists,
+  getSecFromTimestamp,
   getTimeStringsFromSec,
   promisifyProcess,
   shuffleArray
@@ -208,6 +209,8 @@ class AppElement extends FocusElement {
     this.cachedMarkStatuses = new Map()
     this.editMode = false
 
+    this.timestampDictionary = new WeakMap()
+
     // We add this is a child later (so that it's on top of every element).
     this.menuLayer = new DisplayElement()
     this.menuLayer.clickThrough = true
@@ -658,7 +661,7 @@ class AppElement extends FocusElement {
     this.tabber.addTab(grouplikeListing)
     this.tabber.selectTab(grouplikeListing)
 
-    grouplikeListing.on('browse', item => grouplikeListing.loadGrouplike(item))
+    grouplikeListing.on('browse', item => this.browse(grouplikeListing, item))
     grouplikeListing.on('download', item => this.SQP.download(item))
     grouplikeListing.on('open', item => this.openSpecialOrThroughSystem(item))
     grouplikeListing.on('queue', (item, opts) => this.handleQueueOptions(item, opts))
@@ -666,7 +669,7 @@ class AppElement extends FocusElement {
     const updateListingsFor = item => {
       for (const grouplikeListing of this.tabber.tabberElements) {
         if (grouplikeListing.grouplike === item) {
-          grouplikeListing.loadGrouplike(item, false)
+          this.browse(grouplikeListing, item, false)
         }
       }
     }
@@ -754,6 +757,7 @@ class AppElement extends FocusElement {
     // Sets up event listeners that are common to ordinary grouplike listings
     // (made by newGrouplikeListing) as well as the queue grouplike listing.
 
+    grouplikeListing.on('timestamp', (item, time) => this.playOrSeek(item, time))
     grouplikeListing.pathElement.on('select', (item, child) => this.reveal(item, child))
     grouplikeListing.on('menu', (item, el) => this.showMenuForItemElement(el, grouplikeListing))
     /*
@@ -775,6 +779,11 @@ class AppElement extends FocusElement {
     return menu
   }
 
+  browse(grouplikeListing, grouplike, ...args) {
+    this.loadTimestampDataInGrouplike(grouplike)
+    grouplikeListing.loadGrouplike(grouplike, ...args)
+  }
+
   reveal(item, child) {
     if (!this.tabberPane.visible) {
       return
@@ -805,6 +814,14 @@ class AppElement extends FocusElement {
     this.SQP.play(item)
   }
 
+  playOrSeek(item, time) {
+    if (!this.config.canControlQueue || !this.config.canControlPlayback) {
+      return
+    }
+
+    this.SQP.playOrSeek(item, time)
+  }
+
   unqueue(item) {
     if (!this.config.canControlQueue) {
       return
@@ -1029,6 +1046,106 @@ class AppElement extends FocusElement {
   }
   */
 
+  expandTimestamps(item, listing) {
+    listing.expandTimestamps(item)
+  }
+
+  collapseTimestamps(item, listing) {
+    listing.collapseTimestamps(item)
+  }
+
+  toggleTimestamps(item, listing) {
+    listing.toggleTimestamps(item)
+  }
+
+  timestampsExpanded(item, listing) {
+    return listing.timestampsExpanded(item)
+  }
+
+  hasTimestampsFile(item) {
+    return !!this.getTimestampsFile(item)
+  }
+
+  getTimestampsFile(item) {
+    // Only tracks have timestamp files!
+    if (!isTrack(item)) {
+      return false
+    }
+
+    return getCorrespondingFileForItem(item, '.timestamps.txt')
+  }
+
+  async loadTimestampDataInGrouplike(grouplike) {
+    // Only load data for a grouplike once.
+    if (this.timestampDictionary.has(grouplike)) {
+      return
+    }
+
+    this.timestampDictionary.set(grouplike, true)
+
+    // There's no parallelization here, but like, whateeeever.
+    for (const item of grouplike.items) {
+      if (this.timestampDictionary.has(item)) {
+        continue
+      }
+
+      if (!this.hasTimestampsFile(item)) {
+        this.timestampDictionary.set(item, false)
+        continue
+      }
+
+      this.timestampDictionary.set(item, null)
+      const data = await this.readTimestampData(item)
+      this.timestampDictionary.set(item, data)
+    }
+  }
+
+  getTimestampData(item) {
+    return this.timestampDictionary.get(item) || null
+  }
+
+  async readTimestampData(item) {
+    const file = this.getTimestampsFile(item)
+
+    if (!file) {
+      return null
+    }
+
+    let filePath
+    try {
+      filePath = url.fileURLToPath(new URL(file.url))
+    } catch (error) {
+      return null
+    }
+
+    let contents
+    try {
+      contents = (await readFile(filePath)).toString()
+    } catch (error) {
+      return null
+    }
+
+    if (contents.startsWith('{')) {
+      try {
+        return JSON.parse(contents)
+      } catch (error) {
+        return null
+      }
+    }
+
+    const lines = contents.split('\n')
+      .filter(line => !line.startsWith('#'))
+      .filter(line => line)
+
+    const data = lines
+      .map(line => line.match(/^\s*([0-9:]+)\s*(\S.*)\s*$/))
+      .filter(match => match)
+      .map(match => ({timestamp: getSecFromTimestamp(match[1]), comment: match[2]}))
+      .filter(({ timestamp: sec }) => !isNaN(sec))
+
+    return data
+  }
+
   openSpecialOrThroughSystem(item) {
     if (item.url.endsWith('.json')) {
       return this.loadPlaylistOrSource(item.url, true)
@@ -1099,9 +1216,15 @@ class AppElement extends FocusElement {
       }
 
       const hasNotesFile = !!getCorrespondingFileForItem(item, '.txt')
+      const timestampsItem = this.hasTimestampsFile(item) && (this.timestampsExpanded(item, listing)
+        ? {label: 'Collapse saved timestamps', action: () => this.collapseTimestamps(item, listing)}
+        : {label: 'Expand saved timestamps', action: () => this.expandTimestamps(item, listing)}
+      )
+
       if (listing.grouplike.isTheQueue && isTrack(item)) {
         return [
           item[parentSymbol] && this.tabberPane.visible && {label: 'Reveal', action: () => this.reveal(item)},
+          timestampsItem,
           {divider: true},
           canControlQueue && {label: 'Play later', action: () => this.playLater(item)},
           canControlQueue && {label: 'Play sooner', action: () => this.playSooner(item)},
@@ -1161,6 +1284,7 @@ class AppElement extends FocusElement {
           canControlQueue && isPlayable(item) && {label: 'Remove from queue', action: () => this.unqueue(item)},
           {divider: true},
 
+          timestampsItem,
           ...(item === this.markGrouplike
             ? [{label: 'Deselect all', action: () => this.unmarkAll()}]
             : [
@@ -1832,6 +1956,7 @@ class GrouplikeListingElement extends Form {
     this.grouplikeData = new WeakMap()
 
     this.autoscrollOffset = null
+    this.expandedTimestamps = []
   }
 
   getNewForm() {
@@ -1920,7 +2045,8 @@ class GrouplikeListingElement extends Form {
     if (isGroup(this.grouplike)) {
       this.grouplikeData.set(this.grouplike, {
         scrollItems: this.form.scrollItems,
-        currentItem: this.currentItem
+        currentItem: this.currentItem,
+        expandedTimestamps: this.expandedTimestamps
       })
     }
   }
@@ -1931,6 +2057,8 @@ class GrouplikeListingElement extends Form {
       this.form.scrollItems = data.scrollItems
       this.form.selectAndShow(data.currentItem)
       this.form.fixLayout()
+      this.expandedTimestamps = data.expandedTimestamps
+      this.buildTimestampItems()
     }
   }
 
@@ -2006,6 +2134,109 @@ class GrouplikeListingElement extends Form {
     }
   }
 
+  expandTimestamps(item) {
+    if (this.grouplike && this.grouplike.items.includes(item)) {
+      this.expandedTimestamps.push(item)
+      this.buildTimestampItems()
+    }
+  }
+
+  collapseTimestamps(item) {
+    const ET = this.expandedTimestamps // :alien:
+    if (ET.includes(item)) {
+      ET.splice(ET.indexOf(item), 1)
+      this.buildTimestampItems()
+    }
+  }
+
+  toggleTimestamps(item) {
+    if (this.timestampsExpanded(item)) {
+      this.collapseTimestamps(item)
+    } else {
+      this.expandTimestamps(item)
+    }
+  }
+
+  timestampsExpanded(item) {
+    this.updateTimestamps()
+    return this.expandedTimestamps.includes(item)
+  }
+
+  updateTimestamps() {
+    const ET = this.expandedTimestamps
+    if (ET) {
+      this.expandedTimestamps = ET.filter(item => this.grouplike.items.includes(item))
+    }
+  }
+
+  buildTimestampItems(item) {
+    const form = this.form
+
+    // We're going to restore this selection later. It's kinda hacky and won't
+    // work if the selected input was itself a timestamp item, but that
+    // [extremely RFC voice] hopefully won't happen!
+    const selectedInput = this.form.inputs[this.form.curIndex]
+
+    // Clear up any existing timestamp items, since we're about to generate new
+    // ones!
+    form.children = form.children.filter(child => !(child instanceof TimestampGrouplikeItemElement))
+    form.inputs = form.inputs.filter(child => !(child instanceof TimestampGrouplikeItemElement))
+
+    this.updateTimestamps()
+
+    if (!this.expandedTimestamps) {
+      // Well that's going to have obvious consequences.
+      return
+    }
+
+    for (const item of this.expandedTimestamps) {
+      // Find the main item element. The items we're about to generate will be
+      // inserted after it.
+      const mainElementIndex = form.inputs.findIndex(el => (
+        el instanceof InteractiveGrouplikeItemElement &&
+        el.item === item
+      ))
+
+      const timestampData = this.app.getTimestampData(item)
+
+      // Oh no.
+      // TODO: This should probably error report lol.
+      if (!timestampData) {
+        continue
+      }
+
+      // Generate some items! Just go over the data list and generate one for
+      // each timestamp.
+      const tsElements = timestampData.map(ts => {
+        const el = new TimestampGrouplikeItemElement(item, ts.timestamp, ts.comment, this.app)
+        el.on('pressed', () => this.emit('timestamp', item, ts.timestamp))
+        return el
+      })
+
+      // Stick 'em in. Form doesn't implement an "insert input" function because
+      // why would life be easy, so we'll mangle the inputs array ourselves.
+
+      form.inputs.splice(mainElementIndex + 1, 0, ...tsElements)
+
+      let previousIndex = mainElementIndex
+      for (const el of tsElements) {
+        // We do addChild rather than a simple splice because addChild does more
+        // stuff than just sticking it in the array (e.g. setting the child's
+        // .parent property). What if addInput gets updated to do more stuff in
+        // a similar fashion? Well, then we're scr*wed! :)
+        form.addChild(el, previousIndex + 1)
+        previousIndex++
+      }
+    }
+
+    const index = form.inputs.indexOf(selectedInput)
+    if (index >= 0) {
+      form.selectInput(form.inputs.indexOf(selectedInput))
+    }
+
+    this.fixAllLayout()
+  }
+
   buildItems(resetIndex = false) {
     if (!this.grouplike) {
       throw new Error('Attempted to call buildItems before a grouplike was loaded')
@@ -2077,6 +2308,7 @@ class GrouplikeListingElement extends Form {
     // already filled by a previous this.curIndex set).
     form.curIndex = form.curIndex
 
+    this.buildTimestampItems()
     this.fixAllLayout()
   }
 
@@ -2096,6 +2328,8 @@ class GrouplikeListingElement extends Form {
       itemElement.on(evtName, (...data) => this.emit(evtName, itemElement.item, ...data))
     }
 
+    itemElement.on('toggle-timestamps', () => this.toggleTimestamps(itemElement.item))
+
     /*
     itemElement.on('unselected labels', () => {
       if (!this.expandLabels) {
@@ -3019,6 +3253,8 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
     } else if (telc.isEnter(keyBuf)) {
       if (isGroup(this.item)) {
         this.emit('browse')
+      } else if (this.app.hasTimestampsFile(this.item)) {
+        this.emit('toggle-timestamps')
       } else if (isTrack(this.item)) {
         this.emit('queue', {where: 'next', play: true})
       } else if (!this.isPlayable) {
@@ -3130,6 +3366,8 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
       writable.write('G')
     } else if (!this.isPlayable) {
       writable.write('F')
+    } else if (this.app.hasTimestampsFile(this.item)) {
+      writable.write(':')
     } else if (record.downloading) {
       writable.write(braille[Math.floor(Date.now() / 250) % 6])
     } else if (this.app.SQP.playingTrack === this.item) {
@@ -3154,6 +3392,48 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
   }
 }
 
+class TimestampGrouplikeItemElement extends BasicGrouplikeItemElement {
+  constructor(item, timestamp, comment, app) {
+    super('')
+
+    this.app = app
+    this.timestamp = timestamp
+    this.comment = comment
+    this.item = item
+  }
+
+  drawTo(writable) {
+    const metadata = this.app.backend.getMetadataFor(this.item)
+    const duration = (metadata && metadata.duration) || 0
+    const strings = getTimeStringsFromSec(this.timestamp, duration)
+
+    this.text = (
+      (duration
+        ? `(${strings.timeDone} - ${strings.percentDone})`
+        : `(${strings.timeDone})`) +
+      (this.comment
+        ? ` ${this.comment}`
+        : '')
+    )
+
+    super.drawTo(writable)
+  }
+
+  writeStatus(writable) {
+    writable.write(ansi.setAttributes([ansi.A_BRIGHT, ansi.C_CYAN]))
+    writable.write('  ')
+    writable.write(ansi.setAttributes([ansi.C_RESET]))
+    writable.write(':')
+    writable.write(ansi.setAttributes([ansi.A_BRIGHT, ansi.C_CYAN]))
+    writable.write(' ')
+    this.drawX += 4
+  }
+
+  getLeftPadding() {
+    return 4
+  }
+}
+
 class ListingJumpElement extends Form {
   constructor() {
     super()