« 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/ui.js
diff options
context:
space:
mode:
Diffstat (limited to 'ui.js')
-rw-r--r--ui.js1268
1 files changed, 1058 insertions, 210 deletions
diff --git a/ui.js b/ui.js
index bd73bdc..6cabb32 100644
--- a/ui.js
+++ b/ui.js
@@ -1,24 +1,45 @@
 // The UI in MTUI! Interfaces with the backend to form the complete mtui app.
 
-'use strict'
+import {spawn} from 'node:child_process'
+import {readFile, writeFile} from 'node:fs/promises'
+import path from 'node:path'
+import url from 'node:url'
 
-const { getAllCrawlersForArg } = require('./crawlers')
-const processSmartPlaylist = require('./smart-playlist')
-const UndoManager = require('./undo-manager')
+import {orderBy} from 'natural-orderby'
+import open from 'open'
 
-const {
+import {Button, Form, ListScrollForm, TextInput} from 'tui-lib/ui/controls'
+import {Dialog} from 'tui-lib/ui/dialogs'
+import {Label, Pane, WrapLabel} from 'tui-lib/ui/presentation'
+import {DisplayElement, FocusElement} from 'tui-lib/ui/primitives'
+
+import * as ansi from 'tui-lib/util/ansi'
+import telc from 'tui-lib/util/telchars'
+import unic from 'tui-lib/util/unichars'
+
+import {getAllCrawlersForArg} from './crawlers.js'
+import {originalSymbol} from './socket.js'
+import processSmartPlaylist from './smart-playlist.js'
+import UndoManager from './undo-manager.js'
+
+import {
   commandExists,
+  getSecFromTimestamp,
   getTimeStringsFromSec,
   promisifyProcess,
-  shuffleArray
-} = require('./general-util')
+  shuffleArray,
+} from './general-util.js'
 
-const {
+import {
   cloneGrouplike,
+  collapseGrouplike,
   countTotalTracks,
+  findItemObject,
   flattenGrouplike,
   getCorrespondingFileForItem,
   getCorrespondingPlayableForFile,
+  getFlatTrackList,
+  getFlatGroupList,
   getItemPath,
   getNameWithoutTrackNumber,
   isGroup,
@@ -28,55 +49,19 @@ const {
   parentSymbol,
   reverseOrderOfGroups,
   searchForItem,
-  shuffleOrderOfGroups
-} = require('./playlist-utils')
+  shuffleOrderOfGroups,
+} from './playlist-utils.js'
 
-const {
+import {
   updateRestoredTracksUsingPlaylists,
   getWaitingTrackData
-} = require('./serialized-backend')
-
-const {
-  ui: {
-    Dialog,
-    DisplayElement,
-    Label,
-    Pane,
-    WrapLabel,
-    form: {
-      Button,
-      FocusElement,
-      Form,
-      ListScrollForm,
-      TextInput,
-    }
-  },
-  util: {
-    ansi,
-    telchars: telc,
-    unichars: unic,
-  }
-} = require('tui-lib')
-
-const {
-  originalSymbol
-} = require('./socket')
+} from './serialized-backend.js'
 
 /* text editor features disabled because theyre very much incomplete and havent
  * gotten much use from me or anyone afaik!
 const TuiTextEditor = require('tui-text-editor')
 */
 
-const { promisify } = require('util')
-const { spawn } = require('child_process')
-const { orderBy } = require('natural-orderby')
-const fs = require('fs')
-const open = require('open')
-const path = require('path')
-const url = require('url')
-const readFile = promisify(fs.readFile)
-const writeFile = promisify(fs.writeFile)
-
 const input = {}
 
 const keyBindings = [
@@ -116,6 +101,8 @@ const keyBindings = [
   // ['isFocusLabels', 'L', {caseless: false}], // todo: better key? to let isToggleLoop be caseless again
   ['isSelectUp', telc.isShiftUp],
   ['isSelectDown', telc.isShiftDown],
+  ['isNextThemeColor', 'c', {caseless: false}],
+  ['isPreviousThemeColor', 'C', {caseless: false}],
 
   ['isPreviousPlayer', telc.isMetaUp],
   ['isPreviousPlayer', [0x1b, 'p']],
@@ -191,7 +178,7 @@ telc.isRight = input.isRight
 telc.isSelect = input.isSelect
 telc.isBackspace = input.isBackspace
 
-class AppElement extends FocusElement {
+export default class AppElement extends FocusElement {
   constructor(backend, config = {}) {
     super()
 
@@ -208,7 +195,8 @@ class AppElement extends FocusElement {
       canControlQueuePlayers: true,
       canProcessMetadata: true,
       canSuspend: true,
-      menubarColor: 4, // blue
+      themeColor: 4, // blue
+      seekToStartThreshold: 3,
       showTabberPane: true,
       stopPlayingUponQuit: true,
       showPartyControls: false
@@ -220,6 +208,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
@@ -228,7 +218,8 @@ class AppElement extends FocusElement {
     this.menubar = new Menubar(this.showContextMenu)
     this.addChild(this.menubar)
 
-    this.menubar.color = this.config.menubarColor
+    this.setThemeColor(this.config.themeColor)
+    this.menubar.on('color', color => this.setThemeColor(color))
 
     this.tabberPane = new Pane()
     this.addChild(this.tabberPane)
@@ -257,7 +248,7 @@ class AppElement extends FocusElement {
     this.logPane.addChild(this.log)
     this.logPane.visible = false
 
-    this.log.on('log-message', () => {
+    this.log.on('log message', () => {
       this.logPane.visible = true
       this.fixLayout()
     })
@@ -273,8 +264,6 @@ class AppElement extends FocusElement {
     this.metadataStatusLabel.visible = false
     this.tabberPane.addChild(this.metadataStatusLabel)
 
-    this.newGrouplikeListing()
-
     this.queueListingElement = new QueueListingElement(this)
     this.setupCommonGrouplikeListingEvents(this.queueListingElement)
     this.queuePane.addChild(this.queueListingElement)
@@ -285,7 +274,7 @@ class AppElement extends FocusElement {
     this.queueTimeLabel = new Label('')
     this.queuePane.addChild(this.queueTimeLabel)
 
-    this.queueListingElement.on('select', item => this.updateQueueLengthLabel())
+    this.queueListingElement.on('select', _item => this.updateQueueLengthLabel())
     this.queueListingElement.on('open', item => this.openSpecialOrThroughSystem(item))
     this.queueListingElement.on('queue', item => this.play(item))
     this.queueListingElement.on('remove', item => this.unqueue(item))
@@ -294,6 +283,11 @@ class AppElement extends FocusElement {
     this.queueListingElement.on('select main listing',
       () => this.selected())
 
+    if (this.config.showPartyControls) {
+      const sharedSourcesListing = this.newGrouplikeListing()
+      sharedSourcesListing.loadGrouplike(this.backend.sharedSourcesGrouplike)
+    }
+
     this.playbackPane = new Pane()
     this.addChild(this.playbackPane)
 
@@ -346,6 +340,7 @@ class AppElement extends FocusElement {
       {value: 'reverse', label: 'Reverse all'},
       {value: 'reverse-groups', label: 'Reverse order of groups'},
       {value: 'alphabetic', label: 'Alphabetically'},
+      {value: 'alphabetic-groups', label: 'Alphabetize order of groups'},
       {value: 'normal', label: 'In order'}
     ], this.showContextMenu)
 
@@ -366,15 +361,17 @@ class AppElement extends FocusElement {
         return [
           {label: playingTrack ? `("${playingTrack.name}")` : '(No track playing.)'},
           {divider: true},
+          {element: this.volumeSlider},
+          {divider: true},
           playingTrack && {element: this.playingControl},
-          {element: this.loopingControl},
-          {element: this.loopQueueControl},
-          {element: this.pauseNextControl},
+          playingTrack && {element: this.loopingControl},
+          playingTrack && {element: this.pauseNextControl},
           {element: this.autoDJControl},
-          {element: this.volumeSlider},
           {divider: true},
           previous && {label: `Previous (${previous.name})`, action: () => this.SQP.playPrevious(playingTrack)},
           next && {label: `Next (${next.name})`, action: () => this.SQP.playNext(playingTrack)},
+          !next && this.SQP.queueEndMode === 'loop' &&
+          {label: `Next (loop queue)`, action: () => this.SQP.playNext(playingTrack)},
           next && {label: '- Play later', action: () => this.playLater(next)}
         ]
       }},
@@ -385,6 +382,8 @@ class AppElement extends FocusElement {
         return [
           {label: `(Queue - ${curIndex >= 0 ? `${curIndex + 1}/` : ''}${items.length} items.)`},
           {divider: true},
+          {element: this.loopModeControl},
+          {divider: true},
           items.length && {label: 'Shuffle', action: () => this.shuffleQueue()},
           items.length && {label: 'Clear', action: () => this.clearQueue()}
         ]
@@ -418,6 +417,20 @@ class AppElement extends FocusElement {
       getEnabled: () => this.config.canControlPlayback
     })
 
+    this.loopModeControl = new InlineListPickerElement('Loop queue?', [
+      {value: 'end', label: 'Don\'t loop'},
+      {value: 'loop', label: 'Loop (same order)'},
+      {value: 'shuffle', label: 'Loop (shuffle)'}
+    ], {
+      setValue: val => {
+        if (this.SQP) {
+          this.SQP.queueEndMode = val
+        }
+      },
+      getValue: () => this.SQP && this.SQP.queueEndMode,
+      showContextMenu: this.showContextMenu
+    })
+
     this.pauseNextControl = new ToggleControl('Pause when this track ends?', {
       setValue: val => this.SQP.setPauseNextTrack(val),
       getValue: () => this.SQP.pauseNextTrack,
@@ -438,7 +451,7 @@ class AppElement extends FocusElement {
 
     this.autoDJControl = new ToggleControl('Enable Auto-DJ?', {
       setValue: val => (this.enableAutoDJ = val),
-      getValue: val => this.enableAutoDJ,
+      getValue: () => this.enableAutoDJ,
       getEnabled: () => this.config.canControlPlayback
     })
 
@@ -461,8 +474,8 @@ class AppElement extends FocusElement {
       'handleAddedQueuePlayer',
       'handleRemovedQueuePlayer',
       'handleLogMessage',
-      'handleGotPartyGrouplike',
-      'handlePartyGrouplikeUpdated',
+      'handleGotSharedSources',
+      'handleSharedSourcesUpdated',
       'handleSetLoopQueueAtEnd'
     ]) {
       this[key] = this[key].bind(this)
@@ -494,10 +507,6 @@ class AppElement extends FocusElement {
     PIE.on('seek back', () => PIE.queuePlayer.seekBack(5))
     PIE.on('seek ahead', () => PIE.queuePlayer.seekAhead(5))
     PIE.on('toggle pause', () => PIE.queuePlayer.togglePause())
-
-    queuePlayer.on('received time data', this.handleReceivedTimeData)
-    queuePlayer.on('playing details', this.handlePlayingDetails)
-    queuePlayer.on('queue updated', this.handleQueueUpdated)
   }
 
   removeQueuePlayerListenersAndUI(queuePlayer, updateUI = true) {
@@ -524,30 +533,39 @@ class AppElement extends FocusElement {
       this.queuePlayersToActOn.splice(index, 1)
     }
 
-    queuePlayer.removeListener('receivedTimeData', this.handleReceivedTimeData)
-    queuePlayer.removeListener('playing details', this.handlePlayingDetails)
-    queuePlayer.removeListener('queue updated', this.handleQueueUpdated)
     queuePlayer.stopPlaying()
   }
 
   attachBackendListeners() {
+    // Backend-specialized listeners
     this.backend.on('processMetadata progress', this.handleProcessMetadataProgress)
     this.backend.on('added queue player', this.handleAddedQueuePlayer)
     this.backend.on('removed queue player', this.handleRemovedQueuePlayer)
     this.backend.on('log message', this.handleLogMessage)
-    this.backend.on('got party grouplike', this.handleGotPartyGrouplike)
-    this.backend.on('party grouplike updated', this.handlePartyGrouplikeUpdated)
-    this.backend.on('set-loop-queue-at-end', this.handleSetLoopQueueAtEnd)
+    this.backend.on('got shared sources', this.handleGotSharedSources)
+    this.backend.on('shared sources updated', this.handleSharedSourcesUpdated)
+    this.backend.on('set loop queue at end', this.handleSetLoopQueueAtEnd)
+
+    // Backend as queue player proxy listeners
+    this.backend.on('QP: playing details', this.handlePlayingDetails)
+    this.backend.on('QP: received time data', this.handleReceivedTimeData)
+    this.backend.on('QP: queue updated', this.handleQueueUpdated)
   }
 
   removeBackendListeners() {
+    // Backend-specialized listeners
     this.backend.removeListener('processMetadata progress', this.handleProcessMetadataProgress)
     this.backend.removeListener('added queue player', this.handleAddedQueuePlayer)
     this.backend.removeListener('removed queue player', this.handleRemovedQueuePlayer)
     this.backend.removeListener('log message', this.handleLogMessage)
-    this.backend.removeListener('got party grouplike', this.handleGotPartyGrouplike)
-    this.backend.removeListener('party grouplike updated', this.handlePartyGrouplikeUpdated)
-    this.backend.removeListener('set-loop-queue-at-end', this.handleSetLoopQueueAtEnd)
+    this.backend.removeListener('got shared sources', this.handleGotSharedSources)
+    this.backend.removeListener('shared sources updated', this.handleSharedSourcesUpdated)
+    this.backend.removeListener('set loop queue at end', this.handleSetLoopQueueAtEnd)
+
+    // Backend as queue player proxy listeners
+    this.backend.removeListener('QP: playing details', this.handlePlayingDetails)
+    this.backend.removeListener('QP: received time data', this.handleReceivedTimeData)
+    this.backend.removeListener('QP: queue updated', this.handleQueueUpdated)
   }
 
   handleAddedQueuePlayer(queuePlayer) {
@@ -565,23 +583,28 @@ class AppElement extends FocusElement {
     this.log.newLogMessage(messageInfo)
   }
 
-  handleGotPartyGrouplike(socketId, partyGrouplike) {
-    this.newPartyTab(socketId, partyGrouplike)
+  handleGotSharedSources(socketId, sharedSources) {
+    for (const grouplikeListing of this.tabber.tabberElements) {
+      if (grouplikeListing.grouplike === this.backend.sharedSourcesGrouplike) {
+        grouplikeListing.loadGrouplike(this.backend.sharedSourcesGrouplike, false)
+      }
+    }
   }
 
-  handlePartyGrouplikeUpdated(socketId, partyGrouplike) {
+  handleSharedSourcesUpdated(socketId, partyGrouplike) {
     for (const grouplikeListing of this.tabber.tabberElements) {
       if (grouplikeListing.grouplike === partyGrouplike) {
         grouplikeListing.loadGrouplike(partyGrouplike, false)
       }
     }
+    this.clearCachedMarkStatuses()
   }
 
   handleSetLoopQueueAtEnd() {
     this.updateQueueLengthLabel()
   }
 
-  async handlePlayingDetails(track, oldTrack, queuePlayer) {
+  async handlePlayingDetails(queuePlayer, track, oldTrack, startTime) {
     const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
     if (PIE) {
       PIE.updateTrack()
@@ -589,11 +612,21 @@ class AppElement extends FocusElement {
 
     if (queuePlayer === this.SQP) {
       this.updateQueueLengthLabel()
+      this.queueListingElement.collapseTimestamps(oldTrack)
       if (track && this.queueListingElement.currentItem === oldTrack) {
         this.queueListingElement.selectAndShow(track)
       }
     }
 
+    // Unfortunately, there isn't really any reliable way to make these work if
+    // the containing queue isn't of the selected queue player.
+    const timestampData = track && this.getTimestampData(track)
+    if (timestampData && queuePlayer === this.SQP) {
+      if (this.queueListingElement.currentItem === track) {
+        this.queueListingElement.selectTimestampAtSec(track, startTime)
+      }
+    }
+
     if (track && this.enableAutoDJ) {
       queuePlayer.setVolumeMultiplier(0.5);
       const message = 'now playing: ' + getNameWithoutTrackNumber(track);
@@ -606,7 +639,7 @@ class AppElement extends FocusElement {
     }
   }
 
-  handleReceivedTimeData(data, queuePlayer) {
+  handleReceivedTimeData(queuePlayer, timeData, oldTimeData) {
     const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
     if (PIE) {
       PIE.updateProgress()
@@ -614,6 +647,7 @@ class AppElement extends FocusElement {
 
     if (queuePlayer === this.SQP) {
       this.updateQueueLengthLabel()
+      this.updateQueueSelection(timeData, oldTimeData)
     }
   }
 
@@ -707,7 +741,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))
@@ -715,7 +749,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)
         }
       }
     }
@@ -803,12 +837,13 @@ 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.pathElement.on('select', (item, child) => this.reveal(item, child))
+    grouplikeListing.on('timestamp', (item, time) => this.playOrSeek(item, time))
+    grouplikeListing.pathElement.on('select', (item, child) => this.revealInLibrary(item, child))
     grouplikeListing.on('menu', (item, el) => this.showMenuForItemElement(el, grouplikeListing))
     /*
     grouplikeListing.on('select', item => this.editNotesFile(item, false))
     grouplikeListing.on('edit-notes', item => {
-      this.reveal(item)
+      this.revealInLibrary(item)
       this.editNotesFile(item, true)
     })
     */
@@ -824,7 +859,12 @@ class AppElement extends FocusElement {
     return menu
   }
 
-  reveal(item, child) {
+  browse(grouplikeListing, grouplike, ...args) {
+    this.loadTimestampDataInGrouplike(grouplike)
+    grouplikeListing.loadGrouplike(grouplike, ...args)
+  }
+
+  revealInLibrary(item, child) {
     if (!this.tabberPane.visible) {
       return
     }
@@ -846,6 +886,13 @@ class AppElement extends FocusElement {
     }
   }
 
+  revealInQueue(item) {
+    const queueListing = this.queueListingElement
+    if (queueListing.selectAndShow(item)) {
+      this.root.select(queueListing)
+    }
+  }
+
   play(item) {
     if (!this.config.canControlQueue) {
       return
@@ -854,6 +901,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
@@ -979,7 +1034,11 @@ class AppElement extends FocusElement {
   }
 
   emitMarkChanged() {
+    this.clearCachedMarkStatuses()
     this.emit('mark changed')
+  }
+
+  clearCachedMarkStatuses() {
     this.cachedMarkStatuses = new Map()
     this.scheduleDrawWithoutPropertyChange()
   }
@@ -1082,6 +1141,138 @@ 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 (!isTrack(item)) {
+        continue
+      }
+
+      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
+  }
+
+  getTimestampAtSec(item, sec) {
+    const timestampData = this.getTimestampData(item)
+    if (!timestampData) {
+      return null
+    }
+
+    // Just like, start from the end, man.
+    // Why doesn't JavaScript have a findIndexFromEnd function???
+    for (let i = timestampData.length - 1; i >= 0; i--) {
+      const ts = timestampData[i];
+      if (
+        ts.timestamp <= sec &&
+        ts.timestampEnd >= sec
+      ) {
+        return ts
+      }
+    }
+
+    return 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 metadata = this.backend.getMetadataFor(item)
+    const duration = (metadata ? metadata.duration : Infinity)
+
+    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))
+      .map((cur, i, arr) =>
+        (i + 1 === arr.length
+          ? {...cur, timestampEnd: duration}
+          : {...cur, timestampEnd: arr[i + 1].timestamp}))
+
+    return data
+  }
+
   openSpecialOrThroughSystem(item) {
     if (item.url.endsWith('.json')) {
       return this.loadPlaylistOrSource(item.url, true)
@@ -1126,21 +1317,119 @@ class AppElement extends FocusElement {
     }
   }
 
+  skipBackOrSeekToStart() {
+    // Perform the same action - skipping to the previous track or seeking to
+    // the start of the current track - for all target queue players. If any is
+    // past an arbitrary time position (default 3 seconds), seek to start; if
+    // all are before this position, skip to previous.
+
+    let maxCurSec = 0
+    this.forEachQueuePlayerToActOn(qp => {
+      if (qp.timeData) {
+        let effectiveCurSec = qp.timeData.curSecTotal
+
+        const ts = (qp.timeData &&
+          this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal))
+
+        if (ts) {
+          effectiveCurSec -= ts.timestamp
+        }
+
+        maxCurSec = Math.max(maxCurSec, effectiveCurSec)
+      }
+    })
+
+    if (Math.floor(maxCurSec) < this.config.seekToStartThreshold) {
+      this.skipBack()
+    } else {
+      this.seekToStart()
+    }
+  }
+
+  seekToStart() {
+    this.actOnQueuePlayers(qp => qp.seekToStart())
+    this.actOnQueuePlayers(qp => {
+      if (!qp.playingTrack) {
+        return
+      }
+
+      const ts = (qp.timeData &&
+        this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal))
+
+      if (ts) {
+        qp.seekTo(ts.timestamp)
+        return
+      }
+
+      qp.seekToStart()
+    })
+  }
+
+  skipBack() {
+    this.actOnQueuePlayers(qp => {
+      if (!qp.playingTrack) {
+        return
+      }
+
+      const ts = (qp.timeData &&
+        this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal))
+
+      if (ts) {
+        const timestampData = this.getTimestampData(qp.playingTrack)
+        const playingTimestampIndex = timestampData.indexOf(ts)
+        const previous = timestampData[playingTimestampIndex - 1]
+        if (previous) {
+          qp.seekTo(previous.timestamp)
+          return
+        }
+      }
+
+      qp.playPrevious(qp.playingTrack, true)
+    })
+  }
+
+  skipAhead() {
+    this.actOnQueuePlayers(qp => {
+      if (!qp.playingTrack) {
+        return
+      }
+
+      const ts = (qp.timeData &&
+        this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal))
+
+      if (ts) {
+        const timestampData = this.getTimestampData(qp.playingTrack)
+        const playingTimestampIndex = timestampData.indexOf(ts)
+        const next = timestampData[playingTimestampIndex + 1]
+        if (next) {
+          qp.seekTo(next.timestamp)
+          return
+        }
+      }
+
+      qp.playNext(qp.playingTrack, true)
+    })
+  }
+
   actOnQueuePlayers(fn) {
-    const actOn = this.queuePlayersToActOn.length ? this.queuePlayersToActOn : [this.SQP]
-    for (const queuePlayer of actOn) {
+    this.forEachQueuePlayerToActOn(queuePlayer => {
       fn(queuePlayer)
       const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
       if (PIE) {
         PIE.updateProgress()
       }
-    }
+    })
+  }
+
+  forEachQueuePlayerToActOn(fn) {
+    const actOn = this.queuePlayersToActOn.length ? this.queuePlayersToActOn : [this.SQP]
+    actOn.forEach(fn)
   }
 
   showMenuForItemElement(el, listing) {
-    const { editMode } = this
+    // const { editMode } = this
     const { canControlQueue, canProcessMetadata } = this.config
-    const anyMarked = editMode && this.markGrouplike.items.length > 0
+    // const anyMarked = editMode && this.markGrouplike.items.length > 0
 
     const generatePageForItem = item => {
       const emitControls = play => () => {
@@ -1151,22 +1440,43 @@ class AppElement extends FocusElement {
         })
       }
 
-      const rootGroup = getItemPath(item)[0]
+      const itemPath = getItemPath(item)
+      const [rootGroup, _partySources, sharedGroup] = itemPath
 
       // This is the hack mentioned in the todo!!!!
-      if (
-        this.config.showPartyControls &&
-        rootGroup.isPartySources &&
-        item[originalSymbol]
-      ) {
-        item = item[originalSymbol]
+      if (this.config.showPartyControls && rootGroup.isPartySources) {
+        const playlists = this.tabber.tabberElements
+          .map(grouplikeListing => getItemPath(grouplikeListing.grouplike)[0])
+          .filter(root => !root.isPartySources)
+
+        let possibleChoices = []
+        if (item.downloaderArg) {
+          possibleChoices = getFlatTrackList({items: playlists})
+        } else if (item.items) {
+          possibleChoices = getFlatGroupList({items: playlists})
+        }
+        if (possibleChoices) {
+          item = findItemObject(item, possibleChoices)
+        }
+
+        if (!item) {
+          return [
+            {label: `(Couldn't find this in your music)`}
+          ]
+        }
       }
 
-      const hasNotesFile = !!getCorrespondingFileForItem(item, '.txt')
+      // 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)}
+      )
+      const isQueued = this.SQP.queueGrouplike.items.includes(item)
 
       if (listing.grouplike.isTheQueue && isTrack(item)) {
         return [
-          item[parentSymbol] && this.tabberPane.visible && {label: 'Reveal', action: () => this.reveal(item)},
+          item[parentSymbol] && this.tabberPane.visible && {label: 'Reveal in library', action: () => this.revealInLibrary(item)},
+          timestampsItem,
           {divider: true},
           canControlQueue && {label: 'Play later', action: () => this.playLater(item)},
           canControlQueue && {label: 'Play sooner', action: () => this.playSooner(item)},
@@ -1200,13 +1510,12 @@ class AppElement extends FocusElement {
           // to move the "mark"/"paste" (etc) code into separate functions,
           // instead of just defining their behavior inside the listing event
           // handlers.
-          /*
-          editMode && {label: isMarked ? 'Unmark' : 'Mark', action: () => el.emit('mark')},
-          anyMarked && {label: 'Paste (above)', action: () => el.emit('paste', {where: 'above'})},
-          anyMarked && {label: 'Paste (below)', action: () => el.emit('paste', {where: 'below'})},
+
+          // editMode && {label: isMarked ? 'Unmark' : 'Mark', action: () => el.emit('mark')},
+          // anyMarked && {label: 'Paste (above)', action: () => el.emit('paste', {where: 'above'})},
+          // anyMarked && {label: 'Paste (below)', action: () => el.emit('paste', {where: 'below'})},
           // anyMarked && !this.isReal && {label: 'Paste', action: () => this.emit('paste')}, // No "above" or "elow" in the label because the "fake" item/row will be replaced (it'll disappear, since there'll be an item in the group)
-          {divider: true},
-          */
+          // {divider: true},
 
           ...((this.config.showPartyControls && !rootGroup.isPartySources)
             ? [
@@ -1219,25 +1528,24 @@ class AppElement extends FocusElement {
               canControlQueue && isPlayable(item) && {label: 'Play!', action: emitControls(true)},
               canControlQueue && isPlayable(item) && {label: 'Queue!', action: emitControls(false)},
               {divider: true},
-
               canProcessMetadata && isGroup(item) && {label: 'Process metadata (new entries)', action: () => setTimeout(() => this.processMetadata(item, false))},
               canProcessMetadata && isGroup(item) && {label: 'Process metadata (reprocess)', action: () => setTimeout(() => this.processMetadata(item, true))},
               canProcessMetadata && isTrack(item) && {label: 'Process metadata', action: () => setTimeout(() => this.processMetadata(item, true))},
               isOpenable(item) && item.url.endsWith('.json') && {label: 'Open (JSON Playlist)', action: () => this.openSpecialOrThroughSystem(item)},
               isOpenable(item) && {label: 'Open (System)', action: () => this.openThroughSystem(item)},
-              /*
-              !hasNotesFile && isPlayable(item) && {label: 'Create notes file', action: () => this.editNotesFile(item, true)},
-              hasNotesFile && isPlayable(item) && {label: 'Edit notes file', action: () => this.editNotesFile(item, true)},
-              */
+              // !hasNotesFile && isPlayable(item) && {label: 'Create notes file', action: () => this.editNotesFile(item, true)},
+              // hasNotesFile && isPlayable(item) && {label: 'Edit notes file', action: () => this.editNotesFile(item, true)},
               canControlQueue && isPlayable(item) && {label: 'Remove from queue', action: () => this.unqueue(item)},
+              isTrack(item) && isQueued && {label: 'Reveal in queue', action: () => this.revealInQueue(item)},
               {divider: true},
-            ]),
 
-          ...(item === this.markGrouplike
-            ? [{label: 'Deselect all', action: () => this.unmarkAll()}]
-            : [
-              this.getMarkStatus(item) !== 'unmarked' && {label: 'Remove from selection', action: () => this.unmarkItem(item)},
-              this.getMarkStatus(item) !== 'marked' && {label: 'Add to selection', action: () => this.markItem(item)}
+              timestampsItem,
+              ...(item === this.markGrouplike
+                ? [{label: 'Deselect all', action: () => this.unmarkAll()}]
+                : [
+                  this.getMarkStatus(item) !== 'unmarked' && {label: 'Remove from selection', action: () => this.unmarkItem(item)},
+                  this.getMarkStatus(item) !== 'marked' && {label: 'Add to selection', action: () => this.markItem(item)}
+                ])
             ])
         ]
       }
@@ -1249,7 +1557,7 @@ class AppElement extends FocusElement {
     ].filter(Boolean)
 
     // TODO: Implement this! :P
-    const isMarked = false
+    // const isMarked = false
 
     this.showContextMenu({
       x: el.absLeft,
@@ -1526,9 +1834,9 @@ class AppElement extends FocusElement {
       } else if (input.isStop(keyBuf)) {
         this.actOnQueuePlayers(qp => qp.stopPlaying())
       } else if (input.isSkipBack(keyBuf)) {
-        this.actOnQueuePlayers(qp => qp.playPrevious(qp.playingTrack, true))
+        this.skipBackOrSeekToStart()
       } else if (input.isSkipAhead(keyBuf)) {
-        this.actOnQueuePlayers(qp => qp.playNext(qp.playingTrack, true))
+        this.skipAhead()
       }
     }
 
@@ -1593,11 +1901,6 @@ class AppElement extends FocusElement {
     })
   }
 
-  newPartyTab(socketId, partyGrouplike) {
-    const listing = this.newGrouplikeListing()
-    listing.loadGrouplike(partyGrouplike)
-  }
-
   cloneCurrentTab() {
     const grouplike = this.tabber.currentElement.grouplike
     const listing = this.newGrouplikeListing()
@@ -1651,30 +1954,45 @@ class AppElement extends FocusElement {
 
     const oldName = item.name
     if (isGroup(item)) {
-      if (order === 'shuffle') {
-        item = {
-          name: `${oldName} (shuffled)`,
-          items: shuffleArray(flattenGrouplike(item).items)
-        }
-      } else if (order === 'shuffle-groups') {
-        item = shuffleOrderOfGroups(item)
-        item.name = `${oldName} (group order shuffled)`
-      } else if (order === 'reverse') {
-        item = {
-          name: `${oldName} (reversed)`,
-          items: flattenGrouplike(item).items.reverse()
-        }
-      } else if (order === 'reverse-groups') {
-        item = reverseOrderOfGroups(item)
-        item.name = `${oldName} (group order reversed)`
-      } else if (order === 'alphabetic') {
-        item = {
-          name: `${oldName} (alphabetic)`,
-          items: orderBy(
-            flattenGrouplike(item).items,
-            t => getNameWithoutTrackNumber(t).replace(/[^a-zA-Z0-9]/g, '')
-          )
-        }
+      switch (order) {
+        case 'shuffle':
+          item = {
+            name: `${oldName} (shuffled)`,
+            items: shuffleArray(flattenGrouplike(item).items)
+          }
+          break
+        case 'shuffle-groups':
+          item = shuffleOrderOfGroups(item)
+          item.name = `${oldName} (group order shuffled)`
+          break
+        case 'reverse':
+          item = {
+            name: `${oldName} (reversed)`,
+            items: flattenGrouplike(item).items.reverse()
+          }
+          break
+        case 'reverse-groups':
+          item = reverseOrderOfGroups(item)
+          item.name = `${oldName} (group order reversed)`
+          break
+        case 'alphabetic':
+          item = {
+            name: `${oldName} (alphabetic)`,
+            items: orderBy(
+              flattenGrouplike(item).items,
+              t => getNameWithoutTrackNumber(t).replace(/[^a-zA-Z0-9]/g, '')
+            )
+          }
+          break
+        case 'alphabetic-groups':
+          item = {
+            name: `${oldName} (group order alphabetic)`,
+            items: orderBy(
+              collapseGrouplike(item).items,
+              t => t.name.replace(/[^a-zA-Z0-9]/g, '')
+            )
+          }
+          break
       }
     } else {
       // Make it into a grouplike that just contains itself.
@@ -1752,9 +2070,14 @@ class AppElement extends FocusElement {
       return
     }
 
-    const { playingTrack, timeData } = this.SQP
+    const { playingTrack, timeData, queueEndMode } = this.SQP
     const { items } = this.SQP.queueGrouplike
-    const { currentItem: selectedTrack } = this.queueListingElement
+    const {
+      currentInput: currentInput,
+      currentItem: selectedTrack
+    } = this.queueListingElement
+
+    const isTimestamp = (currentInput instanceof TimestampGrouplikeItemElement)
 
     let trackRemainSec = 0
     let trackPassedSec = 0
@@ -1767,6 +2090,7 @@ class AppElement extends FocusElement {
 
     const playingIndex = items.indexOf(playingTrack)
     const selectedIndex = items.indexOf(selectedTrack)
+    const timestampData = playingTrack && this.getTimestampData(playingTrack)
 
     // This will be set to a list of tracks, which will later be used to
     // calculate a particular duration (as described below) to be shown in
@@ -1789,7 +2113,10 @@ class AppElement extends FocusElement {
       durationRange = items
       durationAdd = 0
       durationSymbol = ''
-    } else if (selectedIndex === playingIndex) {
+    } else if (
+      selectedIndex === playingIndex &&
+      (!isTimestamp || currentInput.isCurrentTimestamp)
+    ) {
       // Remaining length of the queue.
       if (timeData) {
         durationRange = items.slice(playingIndex + 1)
@@ -1799,20 +2126,40 @@ class AppElement extends FocusElement {
         durationAdd = 0
       }
       durationSymbol = ''
-    } else if (selectedIndex < playingIndex) {
+    } else if (
+      selectedIndex < playingIndex ||
+      (isTimestamp && currentInput.data.timestamp <= trackPassedSec)
+    ) {
       // Time since the selected track ended.
       durationRange = items.slice(selectedIndex + 1, playingIndex)
       durationAdd = trackPassedSec // defaults to 0: no need to check timeData
       durationSymbol = '-'
-    } else if (selectedIndex > playingIndex) {
+      if (isTimestamp) {
+        if (selectedIndex < playingIndex) {
+          durationRange.unshift(items[selectedIndex])
+        }
+        durationAdd -= currentInput.data.timestampEnd
+      }
+    } else if (
+      selectedIndex > playingIndex ||
+      (isTimestamp && currentInput.data.timestamp > trackPassedSec)
+    ) {
       // Time until the selected track begins.
       if (timeData) {
-        durationRange = items.slice(playingIndex + 1, selectedIndex)
-        durationAdd = trackRemainSec
+        if (selectedIndex === playingIndex) {
+          durationRange = []
+          durationAdd = -trackPassedSec
+        } else {
+          durationRange = items.slice(playingIndex + 1, selectedIndex)
+          durationAdd = trackRemainSec
+        }
       } else {
         durationRange = items.slice(playingIndex, selectedIndex)
         durationAdd = 0
       }
+      if (isTimestamp) {
+        durationAdd += currentInput.data.timestamp
+      }
       durationSymbol = '+'
     }
 
@@ -1823,28 +2170,117 @@ class AppElement extends FocusElement {
     const { duration: durationString } = getTimeStringsFromSec(0, durationTotal)
     this.queueTimeLabel.text = `(${durationSymbol + durationString + approxSymbol})`
 
-    let collapseExtraInfo = false
     if (playingTrack) {
-      let insertString
-      const distance = Math.abs(selectedIndex - playingIndex)
-      if (selectedIndex < playingIndex) {
-        insertString = ` (-${distance})`
-        collapseExtraInfo = true
-      } else if (selectedIndex > playingIndex) {
-        insertString = ` (+${distance})`
-        collapseExtraInfo = true
-      } else {
-        insertString = ''
+      let trackPart
+      let trackPartShort
+      let trackPartReallyShort
+
+      {
+        const distance = Math.abs(selectedIndex - playingIndex)
+
+        let insertString
+        let insertStringShort
+        if (selectedIndex < playingIndex) {
+          insertString = ` (-${distance})`
+          insertStringShort = `-${distance}`
+        } else if (selectedIndex > playingIndex) {
+          insertString = ` (+${distance})`
+          insertStringShort = `+${distance}`
+        } else {
+          insertString = ''
+          insertStringShort = ''
+        }
+
+        trackPart = `${playingIndex + 1 + insertString} / ${items.length}`
+        trackPartShort = (insertString
+          ? `${playingIndex + 1 + insertStringShort}/${items.length}`
+          : `${playingIndex + 1}/${items.length}`)
+        trackPartReallyShort = (insertString
+          ? insertStringShort
+          : `#${playingIndex + 1}`)
       }
-      this.queueLengthLabel.text = `(${this.SQP.playSymbol} ${playingIndex + 1 + insertString} / ${items.length})`
+
+      let timestampPart
+
+      if (isTimestamp && selectedIndex === playingIndex) {
+        const selectedTimestampIndex = timestampData.indexOf(currentInput.data)
+
+        const found = timestampData.findIndex(ts => ts.timestamp > trackPassedSec)
+        const playingTimestampIndex = (found >= 0 ? found - 1 : 0)
+        const distance = Math.abs(selectedTimestampIndex - playingTimestampIndex)
+
+        let insertString
+        if (selectedTimestampIndex < playingTimestampIndex) {
+          insertString = ` (-${distance})`
+        } else if (selectedTimestampIndex > playingTimestampIndex) {
+          insertString = ` (+${distance})`
+        } else {
+          insertString = ''
+        }
+
+        timestampPart = `${playingTimestampIndex + 1 + insertString} / ${timestampData.length}`
+      }
+
+      let queueLoopPart
+      let queueLoopPartShort
+
+      if (selectedIndex === playingIndex) {
+        switch (queueEndMode) {
+          case 'loop':
+            queueLoopPart = 'Repeat'
+            queueLoopPartShort = 'R'
+            break
+          case 'shuffle':
+            queueLoopPart = 'Shuffle'
+            queueLoopPartShort = 'S'
+            break
+          case 'end':
+          default:
+            break
+        }
+      }
+
+      let partsTogether
+
+      const all = () => `(${this.SQP.playSymbol} ${partsTogether})`
+      const tooWide = () => all().length > this.queuePane.contentW
+
+      // goto irl
+      determineParts: {
+        if (timestampPart) {
+          if (queueLoopPart) {
+            partsTogether = `${trackPart} : ${timestampPart} »${queueLoopPartShort}`
+          } else {
+            partsTogether = `(${this.SQP.playSymbol} ${trackPart} : ${timestampPart})`
+          }
+          break determineParts
+        }
+
+        if (queueLoopPart) includeQueueLoop: {
+          partsTogether = `${trackPart} » ${queueLoopPart}`
+          if (tooWide()) {
+            partsTogether = `${trackPart} »${queueLoopPartShort}`
+            if (tooWide()) {
+              break includeQueueLoop
+            }
+          }
+          break determineParts
+        }
+
+        partsTogether = trackPart
+        if (tooWide()) {
+          partsTogether = trackPartShort
+          if (tooWide()) {
+            partsTogether = trackPartReallyShort
+          }
+        }
+      }
+
+      this.queueLengthLabel.text = all()
     } else {
       this.queueLengthLabel.text = `(${items.length})`
     }
 
-    if (this.SQP.loopQueueAtEnd) {
-      this.queueLengthLabel.text += (collapseExtraInfo ? ` [L${unic.ELLIPSIS}]` : ` [Looping]`)
-    }
-
     // Layout stuff to position the length and time labels correctly.
     this.queueLengthLabel.centerInParent()
     this.queueTimeLabel.centerInParent()
@@ -1852,13 +2288,65 @@ class AppElement extends FocusElement {
     this.queueTimeLabel.y = this.queuePane.contentH - 1
   }
 
+  updateQueueSelection(timeData, oldTimeData) {
+    if (!timeData) {
+      return
+    }
+
+    const { playingTrack } = this.SQP
+    const { form } = this.queueListingElement
+    const { currentInput } = form
+
+    if (!currentInput || currentInput.item !== playingTrack) {
+      return
+    }
+
+    const timestamps = this.getTimestampData(playingTrack)
+
+    if (!timestamps) {
+      return
+    }
+
+    const tsOld = oldTimeData &&
+      this.getTimestampAtSec(playingTrack, oldTimeData.curSecTotal)
+    const tsNew =
+      this.getTimestampAtSec(playingTrack, timeData.curSecTotal)
+
+    if (
+      tsNew !== tsOld &&
+      currentInput instanceof TimestampGrouplikeItemElement &&
+      currentInput.data === tsOld
+    ) {
+      const index = form.inputs.findIndex(el => (
+        el.item === playingTrack &&
+        el instanceof TimestampGrouplikeItemElement &&
+        el.data === tsNew
+      ))
+
+      if (index === -1) {
+        return
+      }
+
+      form.curIndex = index
+      if (form.isSelected) {
+        form.updateSelectedElement()
+      }
+      form.scrollSelectedElementIntoView()
+    }
+  }
+
+  setThemeColor(color) {
+    this.themeColor = color
+    this.menubar.color = color
+  }
+
   get SQP() {
     // Just a convenient shorthand.
     return this.selectedQueuePlayer
   }
 
   get selectedQueuePlayer() { return this.getDep('selectedQueuePlayer') }
-  set selectedQueuePlayer(v) { return this.setDep('selectedQueuePlayer', v) }
+  set selectedQueuePlayer(v) { this.setDep('selectedQueuePlayer', v) }
 }
 
 class GrouplikeListingElement extends Form {
@@ -1923,6 +2411,7 @@ class GrouplikeListingElement extends Form {
     this.grouplikeData = new WeakMap()
 
     this.autoscrollOffset = null
+    this.expandedTimestamps = []
   }
 
   getNewForm() {
@@ -2011,7 +2500,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
       })
     }
   }
@@ -2022,6 +2512,8 @@ class GrouplikeListingElement extends Form {
       this.form.scrollItems = data.scrollItems
       this.form.selectAndShow(data.currentItem)
       this.form.fixLayout()
+      this.expandedTimestamps = data.expandedTimestamps
+      this.buildTimestampItems()
     }
   }
 
@@ -2097,6 +2589,186 @@ class GrouplikeListingElement extends Form {
     }
   }
 
+  expandTimestamps(item) {
+    if (this.grouplike && this.grouplike.items.includes(item)) {
+      const ET = this.expandedTimestamps
+      if (!ET.includes(item)) {
+        this.expandedTimestamps.push(item)
+        this.buildTimestampItems()
+
+        if (this.currentItem === item) {
+          if (this.isSelected) {
+            this.form.selectInput(this.form.inputs[this.form.curIndex + 1])
+          } else {
+            this.form.curIndex += 1
+          }
+        }
+      }
+    }
+  }
+
+  collapseTimestamps(item) {
+    const ET = this.expandedTimestamps // :alien:
+    if (ET.includes(item)) {
+      const restore = (this.currentItem === item)
+
+      ET.splice(ET.indexOf(item), 1)
+      this.buildTimestampItems()
+
+      if (restore) {
+        const { form } = this
+        const index = form.inputs.findIndex(inp => inp.item === item)
+        form.curIndex = index
+        if (form.isSelected) {
+          form.updateSelectedElement()
+        }
+        form.scrollSelectedElementIntoView()
+      }
+    }
+  }
+
+  toggleTimestamps(item) {
+    if (this.timestampsExpanded(item)) {
+      this.collapseTimestamps(item)
+    } else {
+      this.expandTimestamps(item)
+    }
+  }
+
+  timestampsExpanded(item) {
+    this.updateTimestamps()
+    return this.expandedTimestamps.includes(item)
+  }
+
+  selectTimestampAtSec(item, sec) {
+    this.expandTimestamps(item)
+
+    const { form } = this
+    let index = form.inputs.findIndex(el => (
+      el.item === item &&
+      el instanceof TimestampGrouplikeItemElement &&
+      el.data.timestamp >= sec
+    ))
+
+    if (index === -1) {
+      index = form.inputs.findIndex(el => el.item === item)
+      if (index === -1) {
+        return
+      }
+    }
+
+    form.curIndex = index
+    if (form.isSelected) {
+      form.updateSelectedElement()
+    }
+    form.scrollSelectedElementIntoView()
+  }
+
+  updateTimestamps() {
+    const ET = this.expandedTimestamps
+    if (ET) {
+      this.expandedTimestamps = ET.filter(item => this.grouplike.items.includes(item))
+    }
+  }
+
+  restoreSelectedInput(restoreInput) {
+    const { form } = this
+    const { inputs, currentInput } = form
+
+    if (currentInput === restoreInput) {
+      return
+    }
+
+    let inputToSelect
+
+    if (inputs.includes(restoreInput)) {
+      inputToSelect = restoreInput
+    } else if (restoreInput instanceof InteractiveGrouplikeItemElement) {
+      inputToSelect = inputs.find(input =>
+        input.item === restoreInput.item &&
+        input instanceof InteractiveGrouplikeItemElement
+      )
+    } else if (restoreInput instanceof TimestampGrouplikeItemElement) {
+      inputToSelect = inputs.find(input =>
+        input.data === restoreInput.data &&
+        input instanceof TimestampGrouplikeItemElement
+      )
+    }
+
+    if (!inputToSelect) {
+      return
+    }
+
+    form.curIndex = inputs.indexOf(inputToSelect)
+    if (form.isSelected) {
+      form.updateSelectedElement()
+    }
+    form.scrollSelectedElementIntoView()
+  }
+
+  buildTimestampItems(restoreInput = this.currentInput) {
+    const form = this.form
+
+    // 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, timestampData, this.app)
+        el.on('pressed', () => this.emit('timestamp', item, ts.timestamp))
+        if (this.grouplike.isTheQueue) {
+          el.hideMetadata = true
+        }
+        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++
+      }
+    }
+
+    this.restoreSelectedInput(restoreInput)
+    this.scheduleDrawWithoutPropertyChange()
+    this.fixAllLayout()
+  }
+
   buildItems(resetIndex = false) {
     if (!this.grouplike) {
       throw new Error('Attempted to call buildItems before a grouplike was loaded')
@@ -2104,6 +2776,7 @@ class GrouplikeListingElement extends Form {
 
     this.commentLabel.text = this.grouplike.comment || ''
 
+    const restoreInput = this.form.currentInput
     const wasSelected = this.isSelected
     const form = this.form
 
@@ -2164,8 +2837,11 @@ class GrouplikeListingElement extends Form {
       }
     }
 
+    this.buildTimestampItems(restoreInput)
+
     // Just to make the selected-track-info bar fill right away (if it wasn't
     // already filled by a previous this.curIndex set).
+    /* eslint-disable-next-line no-self-assign */
     form.curIndex = form.curIndex
 
     this.fixAllLayout()
@@ -2187,6 +2863,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) {
@@ -2295,9 +2973,13 @@ class GrouplikeListingElement extends Form {
   }
 
   get currentItem() {
-    const element = this.form.inputs[this.form.curIndex] || null
+    const element = this.currentInput
     return element && element.item
   }
+
+  get currentInput() {
+    return this.form.currentInput
+  }
 }
 
 class GrouplikeListingForm extends ListScrollForm {
@@ -2312,6 +2994,10 @@ class GrouplikeListingForm extends ListScrollForm {
   }
 
   keyPressed(keyBuf) {
+    if (this.inputs.length === 0) {
+      return
+    }
+
     if (input.isSelectUp(keyBuf)) {
       this.selectUp()
     } else if (input.isSelectDown(keyBuf)) {
@@ -2327,7 +3013,6 @@ class GrouplikeListingForm extends ListScrollForm {
   set curIndex(newIndex) {
     this.setDep('curIndex', newIndex)
     this.emit('select', this.inputs[this.curIndex])
-    return newIndex
   }
 
   get curIndex() {
@@ -2338,6 +3023,10 @@ class GrouplikeListingForm extends ListScrollForm {
     return Math.max(0, this.inputs.findIndex(el => el instanceof InteractiveGrouplikeItemElement))
   }
 
+  get currentInput() {
+    return this.inputs[this.curIndex]
+  }
+
   selectAndShow(item) {
     const index = this.inputs.findIndex(inp => inp.item === item)
     if (index >= 0) {
@@ -2414,7 +3103,6 @@ class GrouplikeListingForm extends ListScrollForm {
   }
 
   dragLeftRange(item) {
-    const { items } = this.app.markGrouplike
     if (this.selectMode === 'select') {
       if (!this.oldMarkedItems.includes(item)) {
         this.app.unmarkItem(item)
@@ -2636,13 +3324,27 @@ class InlineListPickerElement extends FocusElement {
   // next or previous. (That's the point, it's inline.) This element is mainly
   // useful in forms or ContextMenus.
 
-  constructor(labelText, options, showContextMenu = null) {
+  constructor(labelText, options, optsOrShowContextMenu = null) {
     super()
+
     this.labelText = labelText
     this.options = options
-    this.showContextMenu = showContextMenu
-    this.curIndex = 0
+
+    if (typeof optsOrShowContextMenu === 'function') {
+      this.showContextMenu = optsOrShowContextMenu
+    }
+
+    if (typeof optsOrShowContextMenu === 'object') {
+      const opts = optsOrShowContextMenu
+      this.showContextMenu = opts.showContextMenu
+      this.getValue = opts.getValue
+      this.setValue = opts.setValue
+    }
+
     this.keyboardIdentifier = this.labelText
+
+    this.curIndex = 0
+    this.refreshValue()
   }
 
   fixLayout() {
@@ -2693,17 +3395,7 @@ class InlineListPickerElement extends FocusElement {
     } else if (telc.isLeft(keyBuf)) {
       this.previousOption()
     } else if (input.isMenu(keyBuf) && this.showContextMenu) {
-      this.showContextMenu({
-        x: this.absLeft + ansi.measureColumns(this.labelText) + 1,
-        y: this.absTop + 1,
-        items: this.options.map(({ value, label }, index) => ({
-          label: label,
-          action: () => {
-            this.curIndex = index
-          },
-          isDefault: index === this.curIndex
-        }))
-      })
+      this.showMenu()
     } else {
       return true
     }
@@ -2717,6 +3409,8 @@ class InlineListPickerElement extends FocusElement {
       } else {
         this.root.select(this)
       }
+    } else if (button === 'right') {
+      this.showMenu()
     } else if (button === 'scroll-up') {
       this.previousOption()
     } else if (button === 'scroll-down') {
@@ -2727,11 +3421,40 @@ class InlineListPickerElement extends FocusElement {
     return false
   }
 
+
+  showMenu() {
+    this.showContextMenu({
+      x: this.absLeft + ansi.measureColumns(this.labelText) + 1,
+      y: this.absTop + 1,
+      items: this.options.map(({ label }, index) => ({
+        label,
+        action: () => {
+          this.curIndex = index
+        },
+        isDefault: index === this.curIndex
+      }))
+    })
+  }
+
+  refreshValue() {
+    if (this.getValue) {
+      const value = this.getValue()
+      const index = this.options.findIndex(opt => opt.value === value)
+      if (index >= 0) {
+        this.curIndex = index
+      }
+    }
+  }
+
   nextOption() {
     this.curIndex++
     if (this.curIndex === this.options.length) {
       this.curIndex = 0
     }
+
+    if (this.setValue) {
+      this.setValue(this.curValue)
+    }
   }
 
   previousOption() {
@@ -2739,6 +3462,10 @@ class InlineListPickerElement extends FocusElement {
     if (this.curIndex < 0) {
       this.curIndex = this.options.length - 1
     }
+
+    if (this.setValue) {
+      this.setValue(this.curValue)
+    }
   }
 
   get curValue() {
@@ -2746,7 +3473,7 @@ class InlineListPickerElement extends FocusElement {
   }
 
   get curIndex() { return this.getDep('curIndex') }
-  set curIndex(v) { return this.setDep('curIndex', v) }
+  set curIndex(v) { this.setDep('curIndex', v) }
 }
 
 // Quite hacky, but ATM I can't think of any way to neatly tie getDep/setDep
@@ -2936,6 +3663,9 @@ class ToggleControl extends FocusElement {
   }
 
 
+  // Note: ToggleControl doesn't specify refreshValue because it doesn't have an
+  // internal state for the current value. It sets and draws based on the value
+  // getter provided externally.
   toggle() {
     this.setValue(!this.getValue())
   }
@@ -3110,6 +3840,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) {
@@ -3180,15 +3912,16 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
 
   writeStatus(writable) {
     const markStatus = this.app.getMarkStatus(this.item)
+    const color = this.app.themeColor + 30
 
     if (this.isGroup) {
       // The ANSI attributes here will apply to the rest of the line, too.
       // (We don't reset the active attributes until after drawing the rest of
       // the line.)
       if (markStatus === 'marked' || markStatus === 'partial') {
-        writable.write(ansi.setAttributes([ansi.C_BLUE + 10]))
+        writable.write(ansi.setAttributes([color + 10]))
       } else {
-        writable.write(ansi.setAttributes([ansi.C_BLUE, ansi.A_BRIGHT]))
+        writable.write(ansi.setAttributes([color, ansi.A_BRIGHT]))
       }
     } else if (this.isTrack) {
       if (markStatus === 'marked') {
@@ -3222,9 +3955,11 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
     } else if (!this.isPlayable) {
       writable.write('F')
     } else if (record.downloading) {
-      writable.write(braille[Math.floor(Date.now() / 250) % 6])
+      writable.write(brailleChar)
     } else if (this.app.SQP.playingTrack === this.item) {
       writable.write('\u25B6')
+    } else if (this.app.hasTimestampsFile(this.item)) {
+      writable.write(':')
     } else {
       writable.write(' ')
     }
@@ -3245,6 +3980,102 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
   }
 }
 
+class TimestampGrouplikeItemElement extends BasicGrouplikeItemElement {
+  constructor(item, timestampData, tsDataArray, app) {
+    super('')
+
+    this.app = app
+    this.data = timestampData
+    this.tsData = tsDataArray
+    this.item = item
+    this.hideMetadata = false
+  }
+
+  drawTo(writable) {
+    const { data, tsData } = this
+
+    const metadata = this.app.backend.getMetadataFor(this.item)
+    const last = tsData[tsData.length - 1]
+    const duration = ((metadata && metadata.duration)
+      || last.timestampEnd !== Infinity && last.timestampEnd
+      || last.timestamp)
+    const strings = getTimeStringsFromSec(data.timestamp, duration)
+
+    this.text = (
+      /*
+      (trackDuration
+        ? `(${strings.timeDone} - ${strings.percentDone})`
+        : `(${strings.timeDone})`) +
+        */
+      `(${strings.timeDone})` +
+      (data.comment
+        ? ` ${data.comment}`
+        : '')
+    )
+
+    if (!this.hideMetadata) {
+      const durationString = (data.timestampEnd === Infinity
+        ? 'to end'
+        : getTimeStringsFromSec(0, data.timestampEnd - data.timestamp).duration)
+
+      // Try to line up so there's one column of negative padding - the duration
+      // of the timestamp(s) should start one column before the duration of the
+      // actual track. This makes for a nice nested look!
+      const rightPadding = ' '.repeat(duration > 3600 ? 4 : 2)
+      this.rightText = ` (${durationString})` + rightPadding
+    }
+
+    super.drawTo(writable)
+  }
+
+  writeStatus(writable) {
+    let parts = []
+
+    const color = ansi.setAttributes([ansi.A_BRIGHT, 30 + this.app.themeColor])
+    const reset = ansi.setAttributes([ansi.C_RESET])
+
+    if (this.isCurrentTimestamp) {
+      parts = [
+        color,
+        ' ',
+        // reset,
+        '\u25B6 ',
+        // color,
+        ' '
+      ]
+    } else {
+      parts = [
+        color,
+        '  ',
+        reset,
+        ':',
+        color,
+        ' '
+      ]
+    }
+
+    for (const part of parts) {
+      writable.write(part)
+    }
+
+    this.drawX += 4
+  }
+
+  get isCurrentTimestamp() {
+    const { SQP } = this.app
+    return (
+      SQP.playingTrack === this.item &&
+      SQP.timeData &&
+      SQP.timeData.curSecTotal >= this.data.timestamp &&
+      SQP.timeData.curSecTotal < this.data.timestampEnd
+    )
+  }
+
+  getLeftPadding() {
+    return 4
+  }
+}
+
 class ListingJumpElement extends Form {
   constructor() {
     super()
@@ -3582,7 +4413,6 @@ class PlaybackInfoElement extends FocusElement {
         this.app.backend.queuePlayers.length > 1 && {
           label: 'Delete',
           action: () => {
-            const { parent } = this
             this.app.removeQueuePlayer(this.queuePlayer)
           }
         }
@@ -3609,9 +4439,14 @@ class PlaybackInfoElement extends FocusElement {
     this.isLooping = player.isLooping
     this.isPaused = player.isPaused
 
-    this.progressBarLabel.text = '-'.repeat(Math.floor(this.w / lenSecTotal * curSecTotal))
+    if (duration) {
+      this.progressBarLabel.text = '-'.repeat(Math.floor(this.w / lenSecTotal * curSecTotal))
+      this.progressTextLabel.text = timeDone + ' / ' + duration
+    } else {
+      this.progressBarLabel.text = ''
+      this.progressTextLabel.text = timeDone
+    }
 
-    this.progressTextLabel.text = timeDone + ' / ' + duration
     if (player.isLooping) {
       this.progressTextLabel.text += ' [Looping]'
     }
@@ -3683,17 +4518,17 @@ class PlaybackInfoElement extends FocusElement {
   }
 
   get curSecTotal() { return this.getDep('curSecTotal') }
-  set curSecTotal(v) { return this.setDep('curSecTotal', v) }
+  set curSecTotal(v) { this.setDep('curSecTotal', v) }
   get lenSecTotal() { return this.getDep('lenSecTotal') }
-  set lenSecTotal(v) { return this.setDep('lenSecTotal', v) }
+  set lenSecTotal(v) { this.setDep('lenSecTotal', v) }
   get volume() { return this.getDep('volume') }
-  set volume(v) { return this.setDep('volume', v) }
+  set volume(v) { this.setDep('volume', v) }
   get isLooping() { return this.getDep('isLooping') }
-  set isLooping(v) { return this.setDep('isLooping', v) }
+  set isLooping(v) { this.setDep('isLooping', v) }
   get isPaused() { return this.getDep('isPaused') }
-  set isPaused(v) { return this.setDep('isPaused', v) }
+  set isPaused(v) { this.setDep('isPaused', v) }
   get currentTrack() { return this.getDep('currentTrack') }
-  set currentTrack(v) { return this.setDep('currentTrack', v) }
+  set currentTrack(v) { this.setDep('currentTrack', v) }
 }
 
 class OpenPlaylistDialog extends Dialog {
@@ -4103,6 +4938,16 @@ class ContextMenu extends FocusElement {
       return
     }
 
+    // Call refreshValue() on any items before they're shown, for items that
+    // provide it. (This is handy when reusing the same input across a menu that
+    // might be shown under different contexts.)
+    for (const item of items) {
+      const el = item.element
+      if (!el) continue
+      if (!el.refreshValue) continue
+      el.refreshValue()
+    }
+
     if (!this.root.selectedElement.directAncestors.includes(this)) {
       this.selectedBefore = this.root.selectedElement
     }
@@ -4454,9 +5299,14 @@ class Menubar extends ListScrollForm {
 
     if (this.keyboardSelector.keyPressed(keyBuf)) {
       return false
-    } else if (telc.isCaselessLetter(keyBuf, 'c')) {
+    } else if (input.isNextThemeColor(keyBuf)) {
       // For fun :)
-      this.color = (this.color % 8) + 1
+      this.color = (this.color === 8 ? 1 : this.color + 1)
+      this.emit('color', this.color)
+      return false
+    } else if (input.isPreviousThemeColor(keyBuf)) {
+      this.color = (this.color === 1 ? 8 : this.color - 1)
+      this.emit('color', this.color)
       return false
     } else if (telc.isCaselessLetter(keyBuf, 'a')) {
       this.attribute = (this.attribute % 3) + 1
@@ -4513,9 +5363,9 @@ class Menubar extends ListScrollForm {
   }
 
   get color() { return this.getDep('color') }
-  set color(v) { return this.setDep('color', v) }
+  set color(v) { this.setDep('color', v) }
   get attribute() { return this.getDep('attribute') }
-  set attribute(v) { return this.setDep('attribute', v) }
+  set attribute(v) { this.setDep('attribute', v) }
 }
 
 class PartyBanner extends DisplayElement {
@@ -4680,7 +5530,7 @@ class Log extends ListScrollForm {
     this.addInput(logMessage)
     this.fixLayout()
     this.scrollToEnd()
-    this.emit('log-message', logMessage)
+    this.emit('log message', logMessage)
     return logMessage
   }
 }
@@ -4745,5 +5595,3 @@ class LogMessageLabel extends WrapLabel {
     ].filter(x => x !== null)
   }
 }
-
-module.exports = AppElement