« get me outta code hell

Merge branch 'master' into socket-mtui - mtui - Music Text User Interface - user-friendly command line music player
about summary refs log tree commit diff
diff options
context:
space:
mode:
author(quasar) nebula <towerofnix@gmail.com>2021-03-15 20:57:02 -0300
committer(quasar) nebula <towerofnix@gmail.com>2021-03-15 20:57:02 -0300
commit7dfc24b445d96f46049ea680cd8b81e7200baa71 (patch)
treefeacad19d97dfc722ab81a808f49a4cd6b73dd93
parent3d6c5b9bb1f9a42cebc61cbe3b0b5fb69bc146e8 (diff)
parente13e8790feaaa736aab69191996734a14c808012 (diff)
Merge branch 'master' into socket-mtui
-rw-r--r--backend.js30
-rw-r--r--client.js2
-rw-r--r--guess.js41
-rw-r--r--package-lock.json201
-rw-r--r--players.js2
-rw-r--r--playlist-utils.js80
-rw-r--r--screenshot.pngbin56156 -> 78417 bytes
-rw-r--r--todo.txt36
-rw-r--r--ui.js457
9 files changed, 662 insertions, 187 deletions
diff --git a/backend.js b/backend.js
index f2610d0..d222d10 100644
--- a/backend.js
+++ b/backend.js
@@ -69,13 +69,14 @@ class QueuePlayer extends EventEmitter {
     this.playingTrack = null
     this.queueGrouplike = {name: 'Queue', isTheQueue: true, items: []}
     this.pauseNextTrack = false
-    this.alwaysStartPaused = false
-    this.waitWhenDonePlaying = false
-
+    this.loopQueueAtEnd = false
     this.playedTrackToEnd = false
     this.timeData = null
     this.time = null
 
+    this.alwaysStartPaused = false
+    this.waitWhenDonePlaying = false
+
     this.getPlayer = getPlayer
     this.getRecordFor = getRecordFor
   }
@@ -460,8 +461,14 @@ class QueuePlayer extends EventEmitter {
     if (playingThisTrack) {
       this.playedTrackToEnd = true
       this.emit('done playing', this.playingTrack)
-      if (!this.waitWhenDonePlaying && !this.playNext(item)) {
-        this.clearPlayingTrack()
+      if (!this.waitWhenDonePlaying) {
+        if (!this.playNext(item)) {
+          if (this.loopQueueAtEnd) {
+            this.playFirst()
+          } else {
+            this.clearPlayingTrack()
+          }
+        }
       }
     }
   }
@@ -524,6 +531,15 @@ class QueuePlayer extends EventEmitter {
     return true
   }
 
+  playFirst() {
+    const queue = this.queueGrouplike
+    if (queue.items.length) {
+      this.play(queue.items[0])
+      return true
+    }
+    return false
+  }
+
   clearPlayingTrack() {
     if (this.playingTrack !== null) {
       const oldTrack = this.playingTrack
@@ -609,6 +625,10 @@ class QueuePlayer extends EventEmitter {
     this.emit('set-pause-next-track', !!value)
   }
 
+  setLoopQueueAtEnd(value) {
+    this.loopQueueAtEnd = !!value
+  }
+
   get remainingTracks() {
     const index = this.queueGrouplike.items.indexOf(this.playingTrack)
     const length = this.queueGrouplike.items.length
diff --git a/client.js b/client.js
index 645fe42..aa854ed 100644
--- a/client.js
+++ b/client.js
@@ -66,7 +66,7 @@ const setupClient = async ({backend, writable, interfacer, appConfig}) => {
   let grouplike = {
     name: 'My ~/Music Library',
     comment: (
-      '(Add songs and folders to ~/Music to make them show up here,' +
+      '(Add tracks and folders to ~/Music to make them show up here,' +
       ' or pass mtui your own playlist.json file!)'),
     source: ['crawl-local', os.homedir() + '/Music']
   }
diff --git a/guess.js b/guess.js
index 0748ce8..db9f8e8 100644
--- a/guess.js
+++ b/guess.js
@@ -36,13 +36,15 @@ async function game() {
     process.exit(1)
   }
 
+  const QP = await backend.addQueuePlayer()
+
   // TODO: nah
-  backend.setVolume(60)
+  QP.setVolume(60)
 
   process.stdin.setRawMode(true)
   process.stdin.on('data', async data => {
     if (data[0] === 0x03) {
-      await backend.stopPlaying()
+      await QP.stopPlaying()
       process.exit(0)
     }
   })
@@ -50,9 +52,6 @@ async function game() {
   const sourcePath = process.argv[2] || os.homedir() + '/Music'
   let grouplike = {source: ['crawl-local', sourcePath]}
   grouplike = await processSmartPlaylist(grouplike)
-
-  // TODO: Actually let the user choose this..!
-  const group = grouplike.items.find(item => item.name === 'library')
   const allTracks = flattenGrouplike(grouplike).items
 
   const displayTrack = (track, shouldLimit) => {
@@ -68,8 +67,10 @@ async function game() {
 
   while (allTracks.length) {
     const track = allTracks[Math.floor(Math.random() * allTracks.length)]
-    backend.play(track)
-    await untilEvent(backend, 'playing')
+    QP.setPause(false)
+    const promise = untilEvent(QP, 'playing')
+    QP.play(track)
+    await promise
     console.log('-- Listen! Then press space to pause and make a guess. --')
 
     let startTime = Date.now()
@@ -94,42 +95,46 @@ async function game() {
       write(`\r\x1b[${4 + input.length}C`)
     }
 
-    const echoFn = () => {
+    const fmtTime = () => {
       let t = (playTime + Date.now() - startTime) / 1000
       t = Math.floor(t * 10) / 10
       if (t % 1 === 0) {
         t = t + '.0'
       }
-      write(resetLine + t + 's')
+      return t + 's'
+    }
+
+    const echoFn = () => {
+      write(resetLine + fmtTime())
     }
 
     while (true) {
       let echo
-      if (!backend.player.isPaused) {
+      if (!QP.player.isPaused) {
         echo = setInterval(echoFn, 50)
       }
       const key = await untilEvent(process.stdin, 'data')
       clearInterval(echo)
-      if (key[0] === 0x10 || (key[0] === 0x20 && !backend.player.isPaused)) {
-        if (backend.player.isPaused) {
+      if (key[0] === 0x10 || (key[0] === 0x20 && !QP.player.isPaused)) {
+        if (QP.player.isPaused) {
           startTime = Date.now()
           console.log(resetLine + dim + '<Unpaused.>')
           write(ansi.resetAttributes())
         } else {
+          console.log(resetLine + dim + `<Paused @ ${fmtTime()}. Type the track's name below! ^P to resume.>`)
           playTime += Date.now() - startTime
-          console.log(resetLine + dim + '<Paused. Type the track\'s name below! ^P to resume.>')
           write(ansi.resetAttributes())
           echoFn()
           displayInput()
         }
-        backend.togglePause()
+        QP.togglePause()
       /*
-      } else if (key[0] === 0x3f && (!key.length || !backend.player.isPaused)) {
-        backend.setPause(false)
+      } else if (key[0] === 0x3f && (!key.length || !QP.player.isPaused)) {
+        QP.setPause(false)
         gaveUp = true
         break
       */
-      } else if (backend.player.isPaused) {
+      } else if (QP.player.isPaused) {
         if (telc.isBackspace(key)) {
           input = input.slice(0, -1)
           giveUpNext = false
@@ -153,7 +158,7 @@ async function game() {
             }
           } else {
             if (giveUpNext) {
-              backend.setPause(false)
+              QP.setPause(false)
               gaveUp = true
               break
             } else {
diff --git a/package-lock.json b/package-lock.json
index ae9a42f..3d18627 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,8 +1,207 @@
 {
   "name": "mtui",
   "version": "0.0.1",
-  "lockfileVersion": 1,
+  "lockfileVersion": 2,
   "requires": true,
+  "packages": {
+    "": {
+      "version": "0.0.1",
+      "license": "GPL-3.0",
+      "dependencies": {
+        "command-exists": "^1.2.9",
+        "expand-home-dir": "0.0.3",
+        "mkdirp": "^0.5.5",
+        "natural-orderby": "^2.0.3",
+        "node-fetch": "^2.6.0",
+        "open": "^7.0.4",
+        "sanitize-filename": "^1.6.3",
+        "shortid": "^2.2.15",
+        "tempy": "^0.2.1",
+        "tui-lib": "^0.3.1"
+      },
+      "bin": {
+        "mtui": "index.js"
+      }
+    },
+    "node_modules/clone": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
+      "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/command-exists": {
+      "version": "1.2.9",
+      "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz",
+      "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w=="
+    },
+    "node_modules/crypto-random-string": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz",
+      "integrity": "sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/defaults": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz",
+      "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=",
+      "dependencies": {
+        "clone": "^1.0.2"
+      }
+    },
+    "node_modules/expand-home-dir": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/expand-home-dir/-/expand-home-dir-0.0.3.tgz",
+      "integrity": "sha1-ct6KBIbMKKO71wRjU5iCW1tign0="
+    },
+    "node_modules/is-docker": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.0.0.tgz",
+      "integrity": "sha512-pJEdRugimx4fBMra5z2/5iRdZ63OhYV0vr0Dwm5+xtW4D1FvRkB8hamMIhnWfyJeDdyr/aa7BDyNbtG38VxgoQ==",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-wsl": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+      "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+      "dependencies": {
+        "is-docker": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/minimist": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
+      "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
+    },
+    "node_modules/mkdirp": {
+      "version": "0.5.5",
+      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz",
+      "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==",
+      "dependencies": {
+        "minimist": "^1.2.5"
+      },
+      "bin": {
+        "mkdirp": "bin/cmd.js"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "2.1.11",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-2.1.11.tgz",
+      "integrity": "sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA=="
+    },
+    "node_modules/natural-orderby": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-2.0.3.tgz",
+      "integrity": "sha512-p7KTHxU0CUrcOXe62Zfrb5Z13nLvPhSWR/so3kFulUQU0sgUll2Z0LwpsLN351eOOD+hRGu/F1g+6xDfPeD++Q==",
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/node-fetch": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz",
+      "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==",
+      "engines": {
+        "node": "4.x || >=6.0.0"
+      }
+    },
+    "node_modules/open": {
+      "version": "7.0.4",
+      "resolved": "https://registry.npmjs.org/open/-/open-7.0.4.tgz",
+      "integrity": "sha512-brSA+/yq+b08Hsr4c8fsEW2CRzk1BmfN3SAK/5VCHQ9bdoZJ4qa/+AfR0xHjlbbZUyPkUHs1b8x1RqdyZdkVqQ==",
+      "dependencies": {
+        "is-docker": "^2.0.0",
+        "is-wsl": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/sanitize-filename": {
+      "version": "1.6.3",
+      "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz",
+      "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==",
+      "dependencies": {
+        "truncate-utf8-bytes": "^1.0.0"
+      }
+    },
+    "node_modules/shortid": {
+      "version": "2.2.15",
+      "resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.15.tgz",
+      "integrity": "sha512-5EaCy2mx2Jgc/Fdn9uuDuNIIfWBpzY4XIlhoqtXF6qsf+/+SGZ+FxDdX/ZsMZiWupIWNqAEmiNY4RC+LSmCeOw==",
+      "dependencies": {
+        "nanoid": "^2.1.0"
+      }
+    },
+    "node_modules/temp-dir": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz",
+      "integrity": "sha1-CnwOom06Oa+n4OvqnB/AvE2qAR0=",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/tempy": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.2.1.tgz",
+      "integrity": "sha512-LB83o9bfZGrntdqPuRdanIVCPReam9SOZKW0fOy5I9X3A854GGWi0tjCqoXEk84XIEYBc/x9Hq3EFop/H5wJaw==",
+      "dependencies": {
+        "temp-dir": "^1.0.0",
+        "unique-string": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/truncate-utf8-bytes": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
+      "integrity": "sha1-QFkjkJWS1W94pYGENLC3hInKXys=",
+      "dependencies": {
+        "utf8-byte-length": "^1.0.1"
+      }
+    },
+    "node_modules/tui-lib": {
+      "version": "0.3.1",
+      "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.3.1.tgz",
+      "integrity": "sha512-uCE2j351/b4C2Q3eEhC54EvZiWbgJ/Q3gH5ElS2D+mvRmWbHDzXbPUhcXrx8oOA5rZFZ4iNVMCoLCqzWWZTJyQ==",
+      "dependencies": {
+        "wcwidth": "^1.0.1"
+      }
+    },
+    "node_modules/unique-string": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz",
+      "integrity": "sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=",
+      "dependencies": {
+        "crypto-random-string": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/utf8-byte-length": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz",
+      "integrity": "sha1-9F8VDExm7uloGGUFq5P8u4rWv2E="
+    },
+    "node_modules/wcwidth": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
+      "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=",
+      "dependencies": {
+        "defaults": "^1.0.3"
+      }
+    }
+  },
   "dependencies": {
     "clone": {
       "version": "1.0.4",
diff --git a/players.js b/players.js
index 2056ed7..dde1fbf 100644
--- a/players.js
+++ b/players.js
@@ -126,7 +126,7 @@ module.exports.MPVPlayer = class extends Player {
         if (parseInt(percent) < lastPercent) {
           // mpv forgets commands you sent it whenever it loops, so you
           // have to specify them every time it loops. We do that whenever the
-          // position in the song decreases, since that means it may have
+          // position in the track decreases, since that means it may have
           // looped.
           this.setLoop(this.isLooping)
         }
diff --git a/playlist-utils.js b/playlist-utils.js
index b813181..5fbfff8 100644
--- a/playlist-utils.js
+++ b/playlist-utils.js
@@ -535,36 +535,70 @@ function getTrackIndexInParent(track) {
 
 const nameWithoutTrackNumberSymbol = Symbol('Cached name without track number')
 function getNameWithoutTrackNumber(track) {
-  // Be lazy and reuse an old value if possible! Don't do this if the track's
-  // name has changed.
-  const [oldName, cachedValue] = track[nameWithoutTrackNumberSymbol] || []
-  if (cachedValue && track.name === oldName) {
-    return cachedValue
+  // A "part" is a series of numeric digits, separated from other parts by
+  // whitespace and dashes, always preceding either the first non-numeric/
+  // separator character or (if there are no such characters) the first word
+  // (i.e. last whitespace).
+  const getNumberOfParts = ({ name }) => {
+    const match = name.match(/[^0-9\-\s]/)
+    if (match) {
+      name = name.slice(0, match.index)
+    } else if (name.includes(' ')) {
+      name = name.slice(0, name.lastIndexOf(' '))
+    } else {
+      return 0
+    }
+    name = name.replace(/[\-\s]+$/, '')
+    return name.split(/[\-\s]+/g).length
+  }
+
+  const removeParts = (name, numParts) => {
+    const regex = new RegExp(`([0-9]+[\\-\\s]+){${numParts},${numParts}}`)
+    return track.name.replace(regex, '')
   }
 
-  // This is the expression that matches a track number at the start of
-  // a track's title.
-  const regex = /^[0-9\-\s]+/
+  // Despite this function returning a single string for one track, that value
+  // depends on the names of all other tracks under the same parent. We still
+  // store individual track -> name data on the track object, but the parent
+  // gets an additional cache for the names of its children tracks as well as
+  // the number of "parts" (the value directly based upon those names, and
+  // useful in computing the name data for other children tracks).
 
-  // First we need to determine whether every track in the group even starts
-  // with a track number.
   const parent = track[parentSymbol]
   if (parent) {
-    const names = parent.items.filter(isTrack).map(t => t.name)
-    if (names.some(name => !regex.test(name))) {
-      // If any of the names don't match the track number regex, just return
-      // the track name unmodified.
-      return track.name
+    const [trackNames, cachedNumParts] = parent[nameWithoutTrackNumberSymbol] || []
+    const tracks = parent.items.filter(isTrack)
+    if (trackNames && tracks.length === trackNames.length && tracks.every((t, i) => t.name === trackNames[i])) {
+      const [, oldName, oldNumParts, cachedValue] = track[nameWithoutTrackNumberSymbol] || []
+      if (cachedValue && track.name === oldName && cachedNumParts === oldNumParts) {
+        return cachedValue
+      } else {
+        // Individual track cache outdated.
+        const value = removeParts(track.name, cachedNumParts)
+        track[nameWithoutTrackNumberSymbol] = [true, track.name, cachedNumParts, value]
+        return value
+      }
+    } else {
+      // Group (parent) cache outdated.
+      const numParts = Math.min(...tracks.map(getNumberOfParts))
+      parent[nameWithoutTrackNumberSymbol] = [tracks.map(t => t.name), numParts]
+      // Parent changed so track cache changed is outdated too.
+      const value = removeParts(track.name, numParts)
+      track[nameWithoutTrackNumberSymbol] = [true, track.name, numParts, value]
+      return value
+    }
+  } else {
+    const [oldHadParent, oldName, , cachedValue] = track[nameWithoutTrackNumberSymbol] || []
+    if (cachedValue && !oldHadParent && track.name === oldName) {
+      return cachedValue
+    } else {
+      // Track cache outdated.
+      const numParts = getNumberOfParts(track)
+      const value = removeParts(track.name, numParts)
+      track[nameWithoutTrackNumberSymbol] = [false, track.name, numParts, value]
+      return value
     }
   }
-
-  // Now actually perform the replacement to get rid of the track number!
-  const value = track.name.replace(regex, '')
-
-  // Cache the value, so we don't need to do this whole process again.
-  track[nameWithoutTrackNumberSymbol] = [track.name, value]
-
-  return value
 }
 
 function isGroup(obj) {
diff --git a/screenshot.png b/screenshot.png
index e7abe90..81bab72 100644
--- a/screenshot.png
+++ b/screenshot.png
Binary files differdiff --git a/todo.txt b/todo.txt
index c58c1cb..34784cc 100644
--- a/todo.txt
+++ b/todo.txt
@@ -326,6 +326,14 @@ TODO: Figure out duplicates in the selection system! Right now, it's possible
       but it doesn't seem worth it - better to keep them separate and let us
       explicitly decide when we do or don't want to consider duplicates.)
 
+      (Done! But sort of in the reverse direction: you can't select groups
+       themselves anymore; whether they display as selected is based upon if
+       all their child tracks are selected. Accordingly, groups can also show
+       as "partial" selections, if only some of the tracks are selected.
+       Might be worth revisiting, but I think what I've got implemented is
+       easier to wrap your head around than this stuff, however cool the ideas
+       here probably are.)
+
 TODO: Default to 'after selected track' in context menu, and make pressing Q
       (the shorthand for queuing the selection) act as though that's the
       selected option, instead of queuing at the end of the queue (which is
@@ -462,9 +470,11 @@ TODO: Only count *consistently* formatted text, across all tracks in a group,
       it is the only track in the group which is formatted '## # <text>'.
       It does follow the formatting '## <text>' as all other tracks do, so only
       the first digits, and following whitespace, should be removed.
+      (Done!)
 
 TODO: Related to the above - always keep at least one word. See CANSLP's new
       release "untitled folder", with tracks named "01 1", "02 2", etc.
+      (Done!)
 
 TODO: Update to work with IPC server mpv (and socat).
       (Done!)
@@ -479,13 +489,17 @@ TODO: Expand selection context menu by pressing the heading button! It should
 TODO: Opening the selection contxt menu should show an option to either add or
       remove the cursor-focused item from the selection - this would make
       selection accessible when a keyboard or the shift key is inaccessible.
+      (Done!)
 
 TODO: Integrate the rest of the stuff that handles argv into parseOptions.
+      (Done!)
 
 TODO: Figure out looping not always working consistently. I've tried to deal
       with this before, but it's been broken since switching to socat. Maybe we
       aren't receiving time data as consistently, or aren't re-applying loop
       when we're supposed to?
+      (Update: I'm pretty sure this is from socat messages getting dropped -
+       probably by fault of mpv, not socat or mtui.)
 
 TODO: Show how many tracks remain in a queue player's queue, ala "+1" floated
       to the right, behind the playback position/duration indicator.
@@ -524,8 +538,10 @@ TODO: In that regard, also change the queue length label (just above the time
 
 TODO: Deselecting a grouplike listing (e.g. by clicking elsewhere) should hide
       its "jump to" element.
+      (Done!)
 
 TODO: A "before selected item" option for in the queue menu!
+      (Done!)
 
 TODO: The sorting for library3/C418 seems to be weird???? Could be pointing to
       some bug!
@@ -536,7 +552,27 @@ TODO: Selecting a group from the path listing at the bottom of listings should
       For example: selecting X in W/X/Y/Z would open the directory X and
       select item Y; selecting Z would open the directory Z and select the
       track (or group) which the path element is active on in the first place.
+      (Done!)
 
 TODO: UI to change the directory from which mtui reads music by default!
 
 TODO: file/folder browse-select UI 0_0
+
+TODO: Change any "song" terminology to "track" in the UI.
+      (Done!)
+
+TODO: Empty groups show as selected, lol!
+
+TODO: Multipage context menu doesn't work well in the queue - fix this by
+      adding a multipage heading option (or whatever I called em lol) to the
+      queue context menu!
+
+TODO: Names like "10. Banana" don't get cropped! Dots/dashes *after* a number
+      apparently don't get caught. Oops.
+
+TODO: "BAM #45.3 - no" displays as "BAM #45.no" in the queue? Seems wrong!
+
+TODO: "Challenge 1 (Tricks)" etc in FP World 3 are "Challenge (Tricks)"! Bad.
+
+TODO: Pressing next track (shift+N) on the last track should start the first
+      track, if the queue is being looped.
diff --git a/ui.js b/ui.js
index 3bac8c6..68cda91 100644
--- a/ui.js
+++ b/ui.js
@@ -65,6 +65,7 @@ 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')
@@ -135,6 +136,7 @@ const keyBindings = [
   ['isTogglePause', '5'],
   ['isBackspace', '.'],
   ['isMenu', '+'],
+  ['isMenu', '0'],
   ['isSkipBack', '1'],
   ['isSkipAhead', '3'],
   // Disabled because this is the jump key! Oops.
@@ -210,6 +212,7 @@ class AppElement extends FocusElement {
     // TODO: Move edit mode stuff to the backend!
     this.undoManager = new UndoManager()
     this.markGrouplike = {name: 'Selected Items', items: []}
+    this.cachedMarkStatuses = new Map()
     this.editMode = false
 
     // We add this is a child later (so that it's on top of every element).
@@ -324,11 +327,12 @@ class AppElement extends FocusElement {
     this.addChild(this.menuLayer)
 
     this.whereControl = new InlineListPickerElement('Where?', [
-      {value: 'next-selected', label: 'After selected song'},
-      {value: 'next', label: 'After current song'},
+      {value: 'after-selected', label: 'After selected track'},
+      {value: 'next', label: 'After current track'},
       {value: 'end', label: 'At end of queue'},
       {value: 'distribute-evenly', label: 'Distributed across queue evenly'},
-      {value: 'distribute-randomly', label: 'Distributed across queue randomly'}
+      {value: 'distribute-randomly', label: 'Distributed across queue randomly'},
+      {value: 'before-selected', label: 'Before selected track'}
     ], this.showContextMenu)
 
     this.orderControl = new InlineListPickerElement('Order?', [
@@ -336,6 +340,7 @@ class AppElement extends FocusElement {
       {value: 'shuffle-groups', label: 'Shuffle order of groups'},
       {value: 'reverse', label: 'Reverse all'},
       {value: 'reverse-groups', label: 'Reverse order of groups'},
+      {value: 'alphabetic', label: 'Alphabetically'},
       {value: 'normal', label: 'In order'}
     ], this.showContextMenu)
 
@@ -358,6 +363,7 @@ class AppElement extends FocusElement {
           {divider: true},
           playingTrack && {element: this.playingControl},
           {element: this.loopingControl},
+          {element: this.loopQueueControl},
           {element: this.pauseNextControl},
           {element: this.autoDJControl},
           {element: this.volumeSlider},
@@ -413,6 +419,12 @@ class AppElement extends FocusElement {
       getEnabled: () => this.config.canControlPlayback
     })
 
+    this.loopQueueControl = new ToggleControl('Loop queue?', {
+      setValue: val => this.SQP.setLoopQueueAtEnd(val),
+      getValue: () => this.SQP.loopQueueAtEnd,
+      getEnabled: () => this.config.canControlPlayback
+    })
+
     this.volumeSlider = new SliderElement('Volume', {
       setValue: val => this.SQP.setVolume(val),
       getValue: () => this.SQP.player.volume,
@@ -761,7 +773,7 @@ class AppElement extends FocusElement {
     // Sets up event listeners that are common to ordinary grouplike listings
     // (made by newGrouplikeListing) as well as the queue grouplike listing.
 
-    grouplikeListing.pathElement.on('select', item => this.reveal(item))
+    grouplikeListing.pathElement.on('select', (item, child) => this.reveal(item, child))
     grouplikeListing.on('menu', (item, el) => this.showMenuForItemElement(el, grouplikeListing))
     /*
     grouplikeListing.on('select', item => this.editNotesFile(item, false))
@@ -782,7 +794,7 @@ class AppElement extends FocusElement {
     return menu
   }
 
-  reveal(item) {
+  reveal(item, child) {
     if (!this.tabberPane.visible) {
       return
     }
@@ -793,6 +805,9 @@ class AppElement extends FocusElement {
     const parent = item[parentSymbol]
     if (isGroup(item)) {
       tabberListing.loadGrouplike(item)
+      if (child) {
+        tabberListing.selectAndShow(child)
+      }
     } else if (parent) {
       if (tabberListing.grouplike !== parent) {
         tabberListing.loadGrouplike(parent)
@@ -866,8 +881,73 @@ class AppElement extends FocusElement {
     this.queueListingElement.selectAndShow(item)
   }
 
-  deselectAll() {
-    this.markGrouplike.items.splice(0)
+  replaceMark(items) {
+    this.markGrouplike.items = items.slice(0) // Don't share the array! :)
+    this.emitMarkChanged()
+  }
+
+  unmarkAll() {
+    this.markGrouplike.items = []
+    this.emitMarkChanged()
+  }
+
+  markItem(item) {
+    if (isGroup(item)) {
+      for (const child of item.items) {
+        this.markItem(child)
+      }
+    } else {
+      const { items } = this.markGrouplike
+      if (!items.includes(item)) {
+        items.push(item)
+        this.emitMarkChanged()
+      }
+    }
+  }
+
+  unmarkItem(item) {
+    if (isGroup(item)) {
+      for (const child of item.items) {
+        this.unmarkItem(child)
+      }
+    } else {
+      const { items } = this.markGrouplike
+      if (items.includes(item)) {
+        items.splice(items.indexOf(item), 1)
+        this.emitMarkChanged()
+      }
+    }
+  }
+
+  getMarkStatus(item) {
+    if (!this.cachedMarkStatuses.get(item)) {
+      const { items } = this.markGrouplike
+      let status
+      if (isGroup(item)) {
+        const tracks = flattenGrouplike(item).items
+        if (tracks.every(track => items.includes(track))) {
+          status = 'marked'
+        } else if (tracks.some(track => items.includes(track))) {
+          status = 'partial'
+        } else {
+          status = 'unmarked'
+        }
+      } else {
+        if (items.includes(item)) {
+          status = 'marked'
+        } else {
+          status = 'unmarked'
+        }
+      }
+      this.cachedMarkStatuses.set(item, status)
+    }
+    return this.cachedMarkStatuses.get(item)
+  }
+
+  emitMarkChanged() {
+    this.emit('mark changed')
+    this.cachedMarkStatuses = new Map()
+    this.scheduleDrawWithoutPropertyChange()
   }
 
   pauseAll() {
@@ -1024,99 +1104,104 @@ class AppElement extends FocusElement {
   }
 
   showMenuForItemElement(el, listing) {
-    const emitControls = play => () => {
-      this.handleQueueOptions(item, {
-        where: this.whereControl.curValue,
-        order: this.orderControl.curValue,
-        play: play
-      })
-    }
-
-    let item
-    if (this.markGrouplike.items.length) {
-      item = this.markGrouplike
-    } else {
-      item = el.item
-    }
-
-    // TODO: Implement this! :P
-    const isMarked = false
-
     const { editMode } = this
     const { canControlQueue, canProcessMetadata } = this.config
     const anyMarked = editMode && this.markGrouplike.items.length > 0
-    const hasNotesFile = !!getCorrespondingFileForItem(item, '.txt')
 
-    let items;
-    if (listing.grouplike.isTheQueue && isTrack(item)) {
-      items = [
-        item[parentSymbol] && this.tabberPane.visible && {label: 'Reveal', action: () => this.reveal(item)},
-        {divider: true},
-        canControlQueue && {label: 'Play later', action: () => this.playLater(item)},
-        canControlQueue && {label: 'Play sooner', action: () => this.playSooner(item)},
-        {divider: true},
-        canControlQueue && {label: 'Clear past this track', action: () => this.clearQueuePast(item)},
-        canControlQueue && {label: 'Clear up to this track', action: () => this.clearQueueUpTo(item)},
-        {divider: true},
-        {label: 'Autoscroll', action: () => listing.toggleAutoscroll()},
-        {divider: true},
-        canControlQueue && {label: 'Remove from queue', action: () => this.unqueue(item)}
-      ]
-    } else {
-      const numTracks = countTotalTracks(item)
-      const { string: durationString } = this.backend.getDuration(item)
-      items = [
-        // A label that just shows some brief information about the item.
-        {label:
-          `(${item.name ? `"${item.name}"` : 'Unnamed'} - ` +
-          (isGroup(item) ? ` ${numTracks} track${numTracks === 1 ? '' : 's'}, ` : '') +
-          durationString +
-          ')',
-          keyboardIdentifier: item.name
-        },
+    const generatePageForItem = item => {
+      const emitControls = play => () => {
+        this.handleQueueOptions(item, {
+          where: this.whereControl.curValue,
+          order: this.orderControl.curValue,
+          play: play
+        })
+      }
 
-        // The actual controls!
-        {divider: true},
+      const hasNotesFile = !!getCorrespondingFileForItem(item, '.txt')
+      if (listing.grouplike.isTheQueue && isTrack(item)) {
+        return [
+          item[parentSymbol] && this.tabberPane.visible && {label: 'Reveal', action: () => this.reveal(item)},
+          {divider: true},
+          canControlQueue && {label: 'Play later', action: () => this.playLater(item)},
+          canControlQueue && {label: 'Play sooner', action: () => this.playSooner(item)},
+          {divider: true},
+          canControlQueue && {label: 'Clear past this track', action: () => this.clearQueuePast(item)},
+          canControlQueue && {label: 'Clear up to this track', action: () => this.clearQueueUpTo(item)},
+          {divider: true},
+          {label: 'Autoscroll', action: () => listing.toggleAutoscroll()},
+          {divider: true},
+          canControlQueue && {label: 'Remove from queue', action: () => this.unqueue(item)}
+        ]
+      } else {
+        const numTracks = countTotalTracks(item)
+        const { string: durationString } = this.backend.getDuration(item)
+        return [
+          // A label that just shows some brief information about the item.
+          {label:
+            `(${item.name ? `"${item.name}"` : 'Unnamed'} - ` +
+            (isGroup(item) ? ` ${numTracks} track${numTracks === 1 ? '' : 's'}, ` : '') +
+            durationString +
+            ')',
+            keyboardIdentifier: item.name,
+            isPageSwitcher: true
+          },
 
-        // TODO: Don't emit these on the element (and hence receive them from
-        // the listing) - instead, handle their behavior directly. We'll want
-        // 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'})},
-        // 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},
-        */
+          // The actual controls!
+          {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},
+          // TODO: Don't emit these on the element (and hence receive them from
+          // the listing) - instead, handle their behavior directly. We'll want
+          // 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'})},
+          // 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},
+          */
 
-        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)},
-        {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},
 
-        item === this.markGrouplike && {label: 'Deselect', action: () => this.deselectAll()}
-      ]
+          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)},
+          {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)}
+            ])
+        ]
+      }
     }
 
+    const pages = [
+      this.markGrouplike.items.length && generatePageForItem(this.markGrouplike),
+      el.item && generatePageForItem(el.item)
+    ].filter(Boolean)
+
+    // TODO: Implement this! :P
+    const isMarked = false
+
     this.showContextMenu({
       x: el.absLeft,
       y: el.absTop + 1,
-      items
+      pages
     })
   }
 
@@ -1524,26 +1609,45 @@ class AppElement extends FocusElement {
       } 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, '')
+          )
+        }
       }
     } else {
       // Make it into a grouplike that just contains itself.
       item = {name: oldName, items: [item]}
     }
 
-    if (where === 'next' || where === 'next-selected' || where === 'end') {
+    if (where === 'next' || where === 'after-selected' || where === 'before-selected' || where === 'end') {
+      const selected = this.queueListingElement.currentItem
       let afterItem = null
       if (where === 'next') {
         afterItem = playingTrack
-      } else if (where === 'next-selected') {
-        afterItem = this.queueListingElement.currentItem
+      } else if (where === 'after-selected') {
+        afterItem = selected
+      } else if (where === 'before-selected') {
+        const { items } = this.SQP.queueGrouplike
+        const index = items.indexOf(selected)
+        if (index === 0) {
+          afterItem = 'FRONT'
+        } else if (index > 0) {
+          afterItem = items[index - 1]
+        }
       }
 
       this.SQP.queue(item, afterItem, {
-        movePlayingTrack: order === 'normal'
+        movePlayingTrack: order === 'normal' || order === 'alphabetic'
       })
 
       if (isTrack(passedItem)) {
         this.queueListingElement.selectAndShow(passedItem)
+      } else {
+        this.queueListingElement.selectAndShow(selected)
       }
     } else if (where.startsWith('distribute-')) {
       this.SQP.distributeQueue(item, {
@@ -1823,7 +1927,7 @@ class GrouplikeListingElement extends Form {
         */
       }
     } else if (keyBuf[0] === 1) { // ctrl-A
-      this.toggleSelectAll()
+      this.toggleMarkAll()
     } else {
       return super.keyPressed(keyBuf)
     }
@@ -1864,14 +1968,34 @@ class GrouplikeListingElement extends Form {
     this.form.scrollItems = 0
   }
 
-  toggleSelectAll() {
+  toggleMarkAll() {
     const { items } = this.grouplike
-    if (items.every(item => this.app.markGrouplike.items.includes(item))) {
-      this.app.markGrouplike.items = []
+    const actions = []
+    const tracks = flattenGrouplike(this.grouplike).items
+    if (items.every(item => this.app.getMarkStatus(item) !== 'unmarked')) {
+      if (this.app.markGrouplike.items.length > tracks.length) {
+        actions.push({label: 'Remove from selection', action: () => this.app.unmarkItem(this.grouplike)})
+      }
+      actions.push({label: 'Clear selection', action: () => this.app.unmarkAll()})
     } else {
-      this.app.markGrouplike.items = items.slice(0) // Don't share the array! :)
+      actions.push({label: 'Add to selection', action: () => this.app.markItem(this.grouplike)})
+      if (this.app.markGrouplike.items.some(item => !tracks.includes(item))) {
+        actions.push({label: 'Replace selection', action: () => {
+          this.app.unmarkAll()
+          this.app.markItem(this.grouplike)
+        }})
+      }
+    }
+    if (actions.length === 1) {
+      actions[0].action()
+    } else {
+      const el = this.form.inputs[this.form.curIndex]
+      this.app.showContextMenu({
+        x: el.absLeft,
+        y: el.absTop + 1,
+        items: actions
+      })
     }
-    this.scheduleDrawWithoutPropertyChange()
   }
 
   /*
@@ -2080,15 +2204,21 @@ class GrouplikeListingElement extends Form {
   }
 
   hideJumpElement(isCancel) {
-    if (isCancel) {
-      this.form.curIndex = this.oldFocusedIndex
-      this.form.scrollSelectedElementIntoView()
-    }
-    this.jumpElement.visible = false
-    if (this.jumpElement.isSelected) {
-      this.root.select(this)
+    if (this.jumpElement.visible) {
+      if (isCancel) {
+        this.form.curIndex = this.oldFocusedIndex
+        this.form.scrollSelectedElementIntoView()
+      }
+      this.jumpElement.visible = false
+      if (this.jumpElement.isSelected) {
+        this.root.select(this)
+      }
+      this.fixLayout()
     }
-    this.fixLayout()
+  }
+
+  unselected() {
+    this.hideJumpElement(true)
   }
 
   get tabberLabel() {
@@ -2168,13 +2298,13 @@ class GrouplikeListingForm extends ListScrollForm {
           return
         }
         const { item } = input
-        if (this.app.markGrouplike.items.includes(item)) {
-          this.selectMode = 'deselect'
-        } else {
+        if (this.app.getMarkStatus(item) === 'unmarked') {
           if (!ctrl) {
-            this.app.markGrouplike.items = []
+            this.app.unmarkAll()
           }
           this.selectMode = 'select'
+        } else {
+          this.selectMode = 'deselect'
         }
         if (ctrl) {
           this.dragInputs = [item]
@@ -2211,27 +2341,22 @@ class GrouplikeListingForm extends ListScrollForm {
   }
 
   dragEnteredRange(item) {
-    const { items } = this.app.markGrouplike
     if (this.selectMode === 'select') {
-      if (!items.includes(item)) {
-        items.push(item)
-      }
+      this.app.markItem(item)
     } else if (this.selectMode === 'deselect') {
-      if (items.includes(item)) {
-        items.splice(items.indexOf(item), 1)
-      }
+      this.app.unmarkItem(item)
     }
   }
 
   dragLeftRange(item) {
     const { items } = this.app.markGrouplike
     if (this.selectMode === 'select') {
-      if (items.includes(item) && !this.oldMarkedItems.includes(item)) {
-        items.splice(items.indexOf(item), 1)
+      if (!this.oldMarkedItems.includes(item)) {
+        this.app.unmarkItem(item)
       }
     } else if (this.selectMode === 'deselect') {
-      if (!items.includes(item) && this.oldMarkedItems.includes(item)) {
-        items.push(item)
+      if (this.oldMarkedItems.includes(item)) {
+        this.app.markItem(item)
       }
     }
   }
@@ -2270,11 +2395,13 @@ class GrouplikeListingForm extends ListScrollForm {
         return
       }
       this.keyboardDragDirection = direction
-      this.oldMarkedItems = this.app.markGrouplike.items.slice()
-      if (this.app.markGrouplike.items.includes(item)) {
-        this.selectMode = 'deselect'
-      } else {
+      this.oldMarkedItems = (this.inputs
+        .filter(input => input.item && this.app.getMarkStatus(input.item) !== 'unmarked')
+        .map(input => input.item))
+      if (this.app.getMarkStatus(item) === 'unmarked') {
         this.selectMode = 'select'
+      } else {
+        this.selectMode = 'deselect'
       }
       this.dragEnteredRange(item)
     }
@@ -2987,21 +3114,23 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
   }
 
   writeStatus(writable) {
+    const markStatus = this.app.getMarkStatus(this.item)
+
     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 (this.isMarked) {
+      if (markStatus === 'marked' || markStatus === 'partial') {
         writable.write(ansi.setAttributes([ansi.C_BLUE + 10]))
       } else {
         writable.write(ansi.setAttributes([ansi.C_BLUE, ansi.A_BRIGHT]))
       }
     } else if (this.isTrack) {
-      if (this.isMarked) {
+      if (markStatus === 'marked') {
         writable.write(ansi.setAttributes([ansi.C_WHITE + 10, ansi.C_BLACK, ansi.A_BRIGHT]))
       }
     } else if (!this.isPlayable) {
-      if (this.isMarked) {
+      if (markStatus === 'marked') {
         writable.write(ansi.setAttributes([ansi.C_WHITE + 10, ansi.C_BLACK, ansi.A_BRIGHT]))
       } else {
         writable.write(ansi.setAttributes([ansi.A_DIM]))
@@ -3015,8 +3144,10 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
 
     const record = this.app.backend.getRecordFor(this.item)
 
-    if (this.isMarked) {
-      writable.write('>')
+    if (markStatus === 'marked') {
+      writable.write('+')
+    } else if (markStatus === 'partial') {
+      writable.write('*')
     } else {
       writable.write(' ')
     }
@@ -3036,10 +3167,6 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
     writable.write(' ')
   }
 
-  get isMarked() {
-    return this.app.markGrouplike.items.includes(this.item)
-  }
-
   get isGroup() {
     return isGroup(this.item)
   }
@@ -3113,10 +3240,12 @@ class PathElement extends ListScrollForm {
     const itemPath = getItemPath(item)
     const parentPath = itemPath.slice(0, -1)
 
-    for (const pathItem of parentPath) {
-      const isFirst = pathItem === parentPath[0]
+    for (let i = 0; i < parentPath.length; i++) {
+      const pathItem = parentPath[i]
+      const nextItem = itemPath[i + 1]
+      const isFirst = (i === 0)
       const element = new PathItemElement(pathItem, isFirst)
-      element.on('select', () => this.emit('select', pathItem))
+      element.on('select', () => this.emit('select', pathItem, nextItem))
       element.fixLayout()
       this.addInput(element)
     }
@@ -3861,7 +3990,7 @@ class ContextMenu extends FocusElement {
     this.submenu = null
   }
 
-  show({x = 0, y = 0, items: itemsArg, focusKey = null}) {
+  show({x = 0, y = 0, pages = null, items: itemsArg = null, focusKey = null, pageNum = 0}) {
     this.reload = () => {
       const els = [this.root.selectedElement, ...this.root.selectedElement.directAncestors]
       const focusKey = Object.keys(keyElementMap).find(key => els.includes(keyElementMap[key]))
@@ -3869,6 +3998,39 @@ class ContextMenu extends FocusElement {
       this.show({x, y, items: itemsArg, focusKey})
     }
 
+    this.nextPage = () => {
+      if (pages.length > 1) {
+        pageNum++
+        if (pageNum === pages.length) {
+          pageNum = 0
+        }
+        this.close(false)
+        this.show({x, y, pages, pageNum})
+      }
+    }
+
+    this.previousPage = () => {
+      if (pages.length > 1) {
+        pageNum--
+        if (pageNum === -1) {
+          pageNum = pages.length - 1
+        }
+        this.close(false)
+        this.show({x, y, pages, pageNum})
+      }
+    }
+
+    if (!pages && !itemsArg || pages && itemsArg) {
+      return
+    }
+
+    if (pages) {
+      if (pages.length === 0) {
+        return
+      }
+      itemsArg = pages[pageNum]
+    }
+
     let items = (typeof itemsArg === 'function') ? itemsArg() : itemsArg
 
     items = items.filter(Boolean)
@@ -3918,8 +4080,12 @@ class ContextMenu extends FocusElement {
         wantDivider = true
       } else {
         addDividerIfWanted()
-        const button = new Button(item.label)
-        button.keyboardIdentifier = item.keyboardIdentifier || item.label
+        let label = item.label
+        if (item.isPageSwitcher && pages.length > 1) {
+          label = `\x1b[2m(${pageNum + 1}/${pages.length}) « \x1b[22m${label}\x1b[2m »\x1b[22m`
+        }
+        const button = new Button(label)
+        button.keyboardIdentifier = item.keyboardIdentifier || label
         if (item.action) {
           button.on('pressed', async () => {
             this.restoreSelection()
@@ -3930,6 +4096,12 @@ class ContextMenu extends FocusElement {
             }
           })
         }
+        if (item.isPageSwitcher) {
+          button.on('pressed', async () => {
+            this.nextPage()
+          })
+        }
+        button.item = item
         focusEl = button
         this.form.addInput(button)
         if (item.isDefault) {
@@ -3980,6 +4152,15 @@ class ContextMenu extends FocusElement {
       this.form.scrollToBeginning()
     } else if (input.isScrollToEnd(keyBuf)) {
       this.form.lastInput()
+    } else if (input.isLeft(keyBuf) || input.isRight(keyBuf)) {
+      if (this.form.inputs[this.form.curIndex].item.isPageSwitcher) {
+        if (input.isLeft(keyBuf)) {
+          this.previousPage()
+        } else {
+          this.nextPage()
+        }
+        return false
+      }
     } else {
       return super.keyPressed(keyBuf)
     }