« 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.js304
1 files changed, 263 insertions, 41 deletions
diff --git a/ui.js b/ui.js
index b784688..cb38990 100644
--- a/ui.js
+++ b/ui.js
@@ -18,6 +18,7 @@ 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'
 
@@ -33,9 +34,12 @@ import {
   cloneGrouplike,
   collapseGrouplike,
   countTotalTracks,
+  findItemObject,
   flattenGrouplike,
   getCorrespondingFileForItem,
   getCorrespondingPlayableForFile,
+  getFlatTrackList,
+  getFlatGroupList,
   getItemPath,
   getNameWithoutTrackNumber,
   isGroup,
@@ -49,8 +53,13 @@ import {
   shuffleOrderOfGroups,
 } from './playlist-utils.js'
 
+import {
+  updateRestoredTracksUsingPlaylists,
+  getWaitingTrackData
+} from './serialized-backend.js'
+
 /* text editor features disabled because theyre very much incomplete and havent
- * gotten much use from me or anyonea afaik!
+ * gotten much use from me or anyone afaik!
 const TuiTextEditor = require('tui-text-editor')
 */
 
@@ -179,6 +188,8 @@ export default class AppElement extends FocusElement {
     this.isPartyHost = false
     this.enableAutoDJ = false
 
+    // this.playlistSources = []
+
     this.config = Object.assign({
       canControlPlayback: true,
       canControlQueue: true,
@@ -188,7 +199,8 @@ export default class AppElement extends FocusElement {
       themeColor: 4, // blue
       seekToStartThreshold: 3,
       showTabberPane: true,
-      stopPlayingUponQuit: true
+      stopPlayingUponQuit: true,
+      showPartyControls: false
     }, config)
 
     // TODO: Move edit mode stuff to the backend!
@@ -232,6 +244,18 @@ export default class AppElement extends FocusElement {
     })
     */
 
+    this.logPane = new Pane()
+    this.addChild(this.logPane)
+
+    this.log = new Log()
+    this.logPane.addChild(this.log)
+    this.logPane.visible = false
+
+    this.log.on('log message', () => {
+      this.logPane.visible = true
+      this.fixLayout()
+    })
+
     if (!this.config.showTabberPane) {
       this.tabberPane.visible = false
     }
@@ -243,8 +267,6 @@ export default 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)
@@ -264,6 +286,11 @@ export default 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)
 
@@ -452,12 +479,15 @@ export default class AppElement extends FocusElement {
 
   bindListeners() {
     for (const key of [
-      'handlePlaying',
+      'handlePlayingDetails',
       'handleReceivedTimeData',
       'handleProcessMetadataProgress',
       'handleQueueUpdated',
       'handleAddedQueuePlayer',
       'handleRemovedQueuePlayer',
+      'handleLogMessage',
+      'handleGotSharedSources',
+      'handleSharedSourcesUpdated',
       'handleSetLoopQueueAtEnd'
     ]) {
       this[key] = this[key].bind(this)
@@ -489,10 +519,6 @@ export default 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', this.handlePlaying)
-    queuePlayer.on('queue updated', this.handleQueueUpdated)
   }
 
   removeQueuePlayerListenersAndUI(queuePlayer, updateUI = true) {
@@ -519,24 +545,39 @@ export default class AppElement extends FocusElement {
       this.queuePlayersToActOn.splice(index, 1)
     }
 
-    queuePlayer.removeListener('receivedTimeData', this.handleReceivedTimeData)
-    queuePlayer.removeListener('playing', this.handlePlaying)
-    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('set-loop-queue-at-end', this.handleSetLoopQueueAtEnd)
+    this.backend.on('log message', this.handleLogMessage)
+    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('set-loop-queue-at-end', this.handleSetLoopQueueAtEnd)
+    this.backend.removeListener('log message', this.handleLogMessage)
+    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) {
@@ -550,11 +591,32 @@ export default class AppElement extends FocusElement {
     }
   }
 
+  handleLogMessage(messageInfo) {
+    this.log.newLogMessage(messageInfo)
+  }
+
+  handleGotSharedSources(socketId, sharedSources) {
+    for (const grouplikeListing of this.tabber.tabberElements) {
+      if (grouplikeListing.grouplike === this.backend.sharedSourcesGrouplike) {
+        grouplikeListing.loadGrouplike(this.backend.sharedSourcesGrouplike, false)
+      }
+    }
+  }
+
+  handleSharedSourcesUpdated(socketId, partyGrouplike) {
+    for (const grouplikeListing of this.tabber.tabberElements) {
+      if (grouplikeListing.grouplike === partyGrouplike) {
+        grouplikeListing.loadGrouplike(partyGrouplike, false)
+      }
+    }
+    this.clearCachedMarkStatuses()
+  }
+
   handleSetLoopQueueAtEnd() {
     this.updateQueueLengthLabel()
   }
 
-  async handlePlaying(track, oldTrack, startTime, queuePlayer) {
+  async handlePlayingDetails(queuePlayer, track, oldTrack, startTime) {
     const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
     if (PIE) {
       PIE.updateTrack()
@@ -589,7 +651,7 @@ export default class AppElement extends FocusElement {
     }
   }
 
-  handleReceivedTimeData(timeData, oldTimeData, queuePlayer) {
+  handleReceivedTimeData(queuePlayer, timeData, oldTimeData) {
     const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
     if (PIE) {
       PIE.updateProgress()
@@ -916,6 +978,10 @@ export default class AppElement extends FocusElement {
     this.queueListingElement.selectAndShow(item)
   }
 
+  shareWithParty(item) {
+    this.backend.shareWithParty(item)
+  }
+
   replaceMark(items) {
     this.markGrouplike.items = items.slice(0) // Don't share the array! :)
     this.emitMarkChanged()
@@ -1043,7 +1109,11 @@ export default class AppElement extends FocusElement {
   }
 
   emitMarkChanged() {
+    this.clearCachedMarkStatuses()
     this.emit('mark changed')
+  }
+
+  clearCachedMarkStatuses() {
     this.cachedMarkStatuses = new Map()
     this.scheduleDrawWithoutPropertyChange()
   }
@@ -1445,6 +1515,32 @@ export default class AppElement extends FocusElement {
         })
       }
 
+      const itemPath = getItemPath(item)
+      const [rootGroup, _partySources, sharedGroup] = itemPath
+
+      // This is the hack mentioned in the todo!!!!
+      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)`}
+          ]
+        }
+      }
+
       // TODO: Implement this! :P
       // const isMarked = false
 
@@ -1499,30 +1595,36 @@ export default class AppElement extends FocusElement {
           // 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},
 
-          canControlQueue && isPlayable(item) && {element: this.whereControl},
-          canControlQueue && isGroup(item) && {element: this.orderControl},
-          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)},
-          canControlQueue && isPlayable(item) && {label: 'Remove from queue', action: () => this.unqueue(item)},
-          isTrack(item) && isQueued && {label: 'Reveal in queue', action: () => this.revealInQueue(item)},
-          {divider: true},
-
-          timestampsItem,
-          ...(item === this.markGrouplike
-            ? [{label: 'Deselect all', action: () => this.unmarkAll()}]
+          ...((this.config.showPartyControls && !rootGroup.isPartySources)
+            ? [
+                {label: 'Share with party', action: () => this.shareWithParty(item)},
+                {divider: true}
+              ]
             : [
-                isGroup(item) && {element: this.selectGrouplikeItemsControl},
-                this.getMarkStatus(item) !== 'unmarked' && {label: 'Remove from selection', action: () => this.unmarkItem(item, true)},
-                this.getMarkStatus(item) !== 'marked' && {label: 'Add to selection', action: () => this.markItem(item, true)},
+                canControlQueue && isPlayable(item) && {element: this.whereControl},
+                canControlQueue && isGroup(item) && {element: this.orderControl},
+                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)},
+                canControlQueue && isPlayable(item) && {label: 'Remove from queue', action: () => this.unqueue(item)},
+                isTrack(item) && isQueued && {label: 'Reveal in queue', action: () => this.revealInQueue(item)},
+                {divider: true},
+
+                timestampsItem,
+                ...(item === this.markGrouplike
+                  ? [{label: 'Deselect all', action: () => this.unmarkAll()}]
+                  : [
+                      isGroup(item) && {element: this.selectGrouplikeItemsControl},
+                      this.getMarkStatus(item) !== 'unmarked' && {label: 'Remove from selection', action: () => this.unmarkItem(item)},
+                      this.getMarkStatus(item) !== 'marked' && {label: 'Add to selection', action: () => this.markItem(item)},
+                    ])
               ])
         ]
       }
@@ -1588,6 +1690,9 @@ export default class AppElement extends FocusElement {
 
     grouplike = await processSmartPlaylist(grouplike)
 
+    // this.playlistSources.push(grouplike)
+    // updateRestoredTracksUsingPlaylists(this.backend, this.playlistSources)
+
     if (!this.tabber.currentElement || newTab && this.tabber.currentElement.grouplike) {
       const grouplikeListing = this.newGrouplikeListing()
       grouplikeListing.loadGrouplike(grouplike)
@@ -1701,10 +1806,21 @@ export default class AppElement extends FocusElement {
     }
     */
 
+    if (this.logPane.visible) {
+      this.logPane.w = leftWidth
+      this.logPane.h = 6
+      this.log.fillParent()
+      this.log.fixAllLayout()
+    }
+
     if (this.tabberPane.visible) {
       this.tabberPane.w = leftWidth
       this.tabberPane.y = bottomY
       this.tabberPane.h = topY - this.tabberPane.y
+      if (this.logPane.visible) {
+        this.tabberPane.h -= this.logPane.h
+        this.logPane.y = this.tabberPane.bottom
+      }
       /*
       if (this.textInfoPane.visible) {
         this.tabberPane.h -= this.textInfoPane.h
@@ -4417,9 +4533,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]'
     }
@@ -4430,6 +4551,7 @@ class PlaybackInfoElement extends FocusElement {
 
   refreshTrackText(maxNameWidth = Infinity) {
     const { playingTrack } = this.queuePlayer
+    const waitingTrackData = getWaitingTrackData(this.queuePlayer)
     if (playingTrack) {
       this.currentTrack = playingTrack
       const { name } = playingTrack
@@ -4441,6 +4563,11 @@ class PlaybackInfoElement extends FocusElement {
       this.progressBarLabel.text = ''
       this.progressTextLabel.text = '(Starting..)'
       this.timeData = {}
+    } else if (waitingTrackData) {
+      const { name } = waitingTrackData
+      this.clearInfoText()
+      this.trackNameLabel.text = name
+      this.progressTextLabel.text = '(Waiting to play, once found in playlist source.)'
     } else {
       this.clearInfoText()
     }
@@ -5483,3 +5610,98 @@ class NotesTextEditor extends TuiTextEditor {
   }
 }
 */
+
+class Log extends ListScrollForm {
+  constructor() {
+    super('vertical')
+  }
+
+  newLogMessage(messageInfo) {
+    if (this.inputs.length === 10) {
+      this.removeInput(this.inputs[0])
+    }
+
+    if (messageInfo.mayCombine) {
+      // If a message is specified to "combine", it'll replace an immediately
+      // previous message of the same code and sender.
+      const previous = this.inputs[this.inputs.length - 1]
+      if (
+        previous &&
+        previous.info.code === messageInfo.code &&
+        previous.info.sender === messageInfo.sender
+      ) {
+        // If the code and sender match, just remove the previous message.
+        // It'll be replaced by the one we're about to add!
+        this.removeInput(previous)
+      }
+    }
+
+    const logMessage = new LogMessage(messageInfo)
+    this.addInput(logMessage)
+    this.fixLayout()
+    this.scrollToEnd()
+    this.emit('log message', logMessage)
+    return logMessage
+  }
+}
+
+class LogMessage extends FocusElement {
+  constructor(info) {
+    super()
+
+    this.info = info
+
+    const {
+      text,
+      isVerbose = false
+    } = info
+
+    this.label = new LogMessageLabel(text, isVerbose)
+    this.addChild(this.label)
+  }
+
+  fixLayout() {
+    this.w = this.parent.contentW
+    this.label.w = this.contentW
+    this.h = this.label.h
+  }
+
+  clicked(button) {
+    if (button === 'left') {
+      this.root.select(this)
+      return false
+    }
+  }
+}
+
+class LogMessageLabel extends WrapLabel {
+  constructor(text, isVerbose = false) {
+    super(text)
+
+    this.isVerbose = isVerbose
+  }
+
+  writeTextTo(writable) {
+    const w = this.w
+    const lines = this.getWrappedLines()
+    for (let i = 0; i < lines.length; i++) {
+      const text = this.processFormatting(lines[i])
+      writable.write(ansi.moveCursor(this.absTop + i, this.absLeft))
+      writable.write(text)
+      const width = ansi.measureColumns(text)
+      if (width < w && this.textAttributes.length) {
+        writable.write(ansi.setAttributes([ansi.A_RESET, ...this.textAttributes]))
+        writable.write(' '.repeat(w - width))
+      }
+    }
+  }
+
+  set textAttributes(val) {}
+
+  get textAttributes() {
+    return [
+      this.parent.isSelected ? 40 : null,
+      this.isVerbose ? 2 : null
+    ].filter(x => x !== null)
+  }
+}