« 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
path: root/ui.js
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 /ui.js
parent80577b066352fe2dfecd706302e183a5705c193b (diff)
timestamp files!!!
Diffstat (limited to 'ui.js')
-rw-r--r--ui.js286
1 files changed, 283 insertions, 3 deletions
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()