« get me outta code hell

mtui - Music Text User Interface - user-friendly command line music player
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--players.js3
-rw-r--r--todo.txt6
-rw-r--r--ui.js246
3 files changed, 239 insertions, 16 deletions
diff --git a/players.js b/players.js
index 98fff09..a1a3a13 100644
--- a/players.js
+++ b/players.js
@@ -62,6 +62,9 @@ module.exports.MPVPlayer = class extends Player {
     if (this.isLooping) {
       opts.unshift('--loop')
     }
+    if (this.isPaused) {
+      opts.unshift('--pause')
+    }
     opts.unshift('--volume', this.volume)
     return opts
   }
diff --git a/todo.txt b/todo.txt
index fc21476..84b149d 100644
--- a/todo.txt
+++ b/todo.txt
@@ -241,3 +241,9 @@ TODO: An indicator for the number of tracks in the queue!
 
 TODO: "Reveal" option in queue listing context menu.
       (Done!)
+
+TODO: A menubar!
+      (Done!)
+
+TODO: Make pressing the de-focus menubar key restore the selection even if you
+      selected the menubar by clicking on it.
diff --git a/ui.js b/ui.js
index 253a9d9..033e0e3 100644
--- a/ui.js
+++ b/ui.js
@@ -65,6 +65,7 @@ const keyBindings = [
   ['isQueueAtStart', 'Q', {caseless: false}],
   ['isShuffleQueue', 's'],
   ['isClearQueue', 'c'],
+  ['isFocusMenubar', ';'],
 
   // Number pad
   ['isUp', '8'],
@@ -139,6 +140,12 @@ class AppElement extends FocusElement {
 
     this.loadMetadata()
 
+    // We add this is a child later (so that it's on top of every element).
+    this.menu = new ContextMenu()
+
+    this.menubar = new Menubar(this.menu)
+    this.addChild(this.menubar)
+
     this.paneLeft = new Pane()
     this.addChild(this.paneLeft)
 
@@ -193,14 +200,7 @@ class AppElement extends FocusElement {
     this.alertDialog = new AlertDialog()
     this.setupDialog(this.alertDialog)
 
-    /* Ignore this comment mostly :)  (Because menu isn't a child of pane,
-       so we can append it to the app right away. Helps w/ handling ^C and
-       stuff too.)
-    // If the program were embedded, this.menu should probably be set to the
-    // global menu object for that app (and everything should work fine).
-    // As is, remember to append app.menu to root.
-    */
-    this.menu = new ContextMenu()
+    // Should be placed on top of everything else!
     this.addChild(this.menu)
 
     this.whereControl = new InlineListPickerElement('Where?', [
@@ -215,6 +215,53 @@ class AppElement extends FocusElement {
       {value: 'shuffle-groups', label: 'Shuffle order of groups'},
       {value: 'normal', label: 'In order'}
     ])
+
+    this.menubar.buildItems([
+      {text: 'mtui', menuItems: [
+        {label: 'mtui (perpetual development)'},
+        {divider: true},
+        {label: 'Quit', action: () => this.shutdown()},
+        {label: 'Suspend', action: () => this.suspend()}
+      ]},
+      {text: 'Playback', menuFn: () => {
+        const { items } = this.queueGrouplike
+        const curIndex = items.indexOf(this.playingTrack)
+        const next = (curIndex >= 0) && items[curIndex + 1]
+        const previous = (curIndex >= 0) && items[curIndex - 1]
+
+        return [
+          {label: this.playingTrack ? `("${this.playingTrack.name}")` : '(No track playing.)'},
+          {divider: true},
+          {element: this.playingControl},
+          {element: this.loopingControl},
+          (next || previous) && {divider: true},
+          previous && {label: `Previous (${previous.name})`, action: () => this.playPreviousTrack(this.playingTrack)},
+          next && {label: `Next (${next.name})`, action: () => this.playNextTrack(this.playingTrack)},
+          next && {label: '(...Play Later)', action: () => this.playLater(next)}
+        ]
+      }},
+      {text: 'Queue', menuFn: () => {
+        const { items } = this.queueGrouplike
+        const curIndex = items.indexOf(this.playingTrack)
+
+        return [
+          {label: `(Queue - ${curIndex >= 0 ? `${curIndex + 1}/` : ''}${items.length} items.)`},
+          items.length && {divider: true},
+          items.length && {label: 'Shuffle', action: () => this.shuffleQueue()},
+          items.length && {label: 'Clear', action: () => this.clearQueue()}
+        ]
+      }}
+    ])
+
+    this.playingControl = new ToggleControl('Pause?', {
+      setValue: val => this.setPause(val),
+      getValue: () => this.player.isPaused
+    })
+
+    this.loopingControl = new ToggleControl('Loop current track?', {
+      setValue: val => this.setLoop(val),
+      getValue: () => this.player.isLooping
+    })
   }
 
   selected() {
@@ -336,6 +383,13 @@ class AppElement extends FocusElement {
     }
   }
 
+  playLater(item) {
+    this.handleQueueOptions(item, {
+      where: 'distribute-randomly',
+      skip: true
+    })
+  }
+
   showMenuForItemElement(el, listing) {
     const emitControls = play => () => {
       this.handleQueueOptions(item, {
@@ -354,10 +408,7 @@ class AppElement extends FocusElement {
       items = [
         {label: 'Reveal', action: () => this.reveal(item)},
         {divider: true},
-        {label: 'Play later', action: () => this.handleQueueOptions(item, {
-          where: 'distribute-randomly',
-          skip: true
-        })},
+        {label: 'Play later', action: () => this.playLater(item)},
         {label: 'Remove from queue', action: () => this.unqueueGrouplikeItem(item)}
       ]
     } else {
@@ -485,15 +536,23 @@ class AppElement extends FocusElement {
     this.emit('quitRequested')
   }
 
+  suspend() {
+    this.emit('suspendRequested')
+  }
+
   fixLayout() {
     this.w = this.parent.contentW
     this.h = this.parent.contentH
 
+    this.menubar.fixLayout()
+
     this.paneLeft.w = Math.max(Math.floor(0.8 * this.contentW), this.contentW - 80)
-    this.paneLeft.h = this.contentH - 5
+    this.paneLeft.h = this.contentH - 6
+    this.paneLeft.y = 1
     this.paneRight.x = this.paneLeft.right
     this.paneRight.w = this.contentW - this.paneLeft.right
     this.paneRight.h = this.paneLeft.h
+    this.paneRight.y = 1
     this.playbackPane.y = this.paneLeft.bottom
     this.playbackPane.w = this.contentW
     this.playbackPane.h = this.contentH - this.playbackPane.y
@@ -520,11 +579,15 @@ class AppElement extends FocusElement {
       this.shutdown()
       return
     } else if (keyBuf[0] === 0x1a) { // Ctrl-Z
-      this.emit('suspendRequested')
+      this.suspend()
       return
     }
 
-    if (input.isRight(keyBuf)) {
+    if ((telc.isEscape(keyBuf) || telc.isBackspace(keyBuf)) && this.menubar.isSelected) {
+      this.menubar.restoreSelection()
+    } else if ((telc.isLeft(keyBuf) || telc.isRight(keyBuf)) && this.menubar.isSelected) {
+      return // le sigh
+    } else if (input.isRight(keyBuf)) {
       this.seekAhead(10)
     } else if (input.isLeft(keyBuf)) {
       this.seekBack(10)
@@ -546,6 +609,12 @@ class AppElement extends FocusElement {
       this.root.select(this.tabber)
     } else if (input.isFocusQueue(keyBuf) && this.queueListingElement.selectable) {
       this.root.select(this.queueListingElement)
+    } else if (input.isFocusMenubar(keyBuf)) {
+      if (this.menubar.isSelected) {
+        this.menubar.restoreSelection()
+      } else {
+        this.menubar.select()
+      }
     } else if (keyBuf.equals(Buffer.from([5]))) { // Ctrl-E
       this.editMode = !this.editMode
     } else if (this.editMode && keyBuf.equals(Buffer.from([14]))) { // ctrl-N
@@ -630,10 +699,18 @@ class AppElement extends FocusElement {
     this.player.togglePause()
   }
 
+  setPause(value) {
+    this.player.setPause(value)
+  }
+
   toggleLoop() {
     this.player.toggleLoop()
   }
 
+  setLoop(value) {
+    this.player.setLoop(value)
+  }
+
   volUp(amount = 10) {
     this.player.volUp(amount)
   }
@@ -1724,6 +1801,63 @@ class InlineListPickerElement extends FocusElement {
   }
 }
 
+class ToggleControl extends FocusElement {
+  constructor(labelText, {setValue, getValue}) {
+    super()
+    this.labelText = labelText
+    this.setValue = setValue
+    this.getValue = getValue
+    this.keyboardIdentifier = this.labelText
+  }
+
+  keyPressed(keyBuf) {
+    if (input.isSelect(keyBuf)) {
+      this.toggle()
+    }
+  }
+
+  clicked(button) {
+    if (button === 'left') {
+      if (this.isSelected) {
+        this.toggle()
+      } else {
+        this.root.select(this)
+      }
+    } else if (button === 'scroll-up' || button === 'scroll-down') {
+      this.toggle()
+    } else {
+      return true
+    }
+    return false
+  }
+
+
+  toggle() {
+    this.setValue(!this.getValue())
+  }
+
+  fixLayout() {
+    // Same general principle as ToggleControl - fill the parent, but always
+    // fit ourselves!
+    this.w = Math.max(this.parent.contentW, this.labelText.length + 5)
+    this.h = 1
+  }
+
+  drawTo(writable) {
+    if (this.isSelected) {
+      writable.write(ansi.invert())
+    }
+
+    writable.write(ansi.moveCursor(this.absTop, this.absLeft))
+
+    writable.write(this.getValue() ? '[X] ' : '[.] ')
+    writable.write(this.labelText)
+    writable.write(' '.repeat(this.w - (this.labelText.length + 4)))
+
+    writable.write(ansi.resetAttributes())
+  }
+}
+
 class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
   constructor(item, app) {
     super(item.name)
@@ -2504,7 +2638,7 @@ class ContextMenu extends FocusElement {
     this.pane.fillParent()
     this.form.fillParent()
 
-    // After everything else, do a second pass to apply the decided  width
+    // After everything else, do a second pass to apply the decided width
     // to every element, so that they expand to all be the same width.
     // In order to change the width of a button (which is what these elements
     // are), we need to append space characters.
@@ -2624,4 +2758,84 @@ class KeyboardSelector {
   }
 }
 
+class Menubar extends ListScrollForm {
+  constructor(contextMenu) {
+    super('horizontal')
+
+    this.contextMenu = contextMenu
+  }
+
+  select() {
+    // The context menu disappears when it's deselected, so we really want to
+    // use whatever element was selected before the menu was opened.
+    if (this.contextMenu.isSelected) {
+      // ...Unless it was the menubar that was already selected.
+      if (!this.contextMenu.selectedBefore.directAncestors.includes(this)) {
+        this.selectedBefore = this.contextMenu.selectedBefore
+      }
+    } else {
+      this.selectedBefore = this.root.selectedElement
+    }
+
+    this.root.select(this)
+  }
+
+  selected() {
+    super.selected()
+  }
+
+  keyPressed(keyBuf) {
+    super.keyPressed(keyBuf)
+
+    // Don't pause the music from the menubar!
+    if (telc.isSpace(keyBuf)) {
+      return false
+    }
+  }
+
+  restoreSelection() {
+    if (this.selectedBefore) {
+      this.root.select(this.selectedBefore)
+      this.selectedBefore = null
+    }
+  }
+
+  buildItems(array) {
+    for (const {text, menuItems, menuFn} of array) {
+      const button = new Button(` ${text} `)
+
+      const container = new FocusElement()
+      container.addChild(button)
+      button.x = 1
+      container.w = button.w + 2
+      container.h = 1
+      container.selected = () => this.root.select(button)
+
+      button.on('pressed', () => {
+        this.contextMenu.show({
+          x: container.absLeft, y: container.absY + 1,
+          items: menuFn ? menuFn() : menuItems
+        })
+      })
+
+      this.addInput(container)
+    }
+  }
+
+  fixLayout() {
+    this.x = 0
+    this.y = 0
+    this.w = this.parent.contentW
+    this.h = 1
+    super.fixLayout()
+  }
+
+  drawTo(writable) {
+    writable.write(ansi.moveCursor(this.absTop, this.absLeft))
+    writable.write(ansi.setAttributes([ansi.C_BLUE, ansi.A_DIM, ansi.A_INVERT, ansi.C_WHITE + 10]))
+    writable.write(' '.repeat(this.w))
+    writable.write(ansi.resetAttributes())
+  }
+}
+
 module.exports.AppElement = AppElement