« 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--backend.js520
-rwxr-xr-xindex.js47
-rw-r--r--ui.js697
3 files changed, 692 insertions, 572 deletions
diff --git a/backend.js b/backend.js
new file mode 100644
index 0000000..c59121a
--- /dev/null
+++ b/backend.js
@@ -0,0 +1,520 @@
+// MTUI "server" - this just acts as the backend for mtui, controlling the
+// player, queue, etc. It's entirely independent from tui-lib/UI.
+
+'use strict'
+
+const { getDownloaderFor } = require('./downloaders')
+const { getMetadataReaderFor } = require('./metadata-readers')
+const { getPlayer } = require('./players')
+const RecordStore = require('./record-store')
+
+const {
+  shuffleArray,
+  throttlePromise
+} = require('./general-util')
+
+const {
+  isGroup,
+  isTrack,
+  flattenGrouplike,
+  getItemPathString,
+  parentSymbol
+} = require('./playlist-utils')
+
+const { promisify } = require('util')
+const EventEmitter = require('events')
+const fs = require('fs')
+const writeFile = promisify(fs.writeFile)
+const readFile = promisify(fs.readFile)
+
+class Backend extends EventEmitter {
+  constructor() {
+    super()
+
+    this.player = null
+    this.playingTrack = null
+    this.recordStore = new RecordStore()
+    this.throttleMetadata = throttlePromise(10)
+    this.metadataDictionary = {}
+    this.queueGrouplike = {name: 'Queue', isTheQueue: true, items: []}
+
+    this.rootDirectory = process.env.HOME + '/.mtui'
+    this.metadataPath = this.rootDirectory + '/track-metadata.json'
+  }
+
+
+  async setup() {
+    this.player = await getPlayer()
+
+    if (!this.player) {
+      return {
+        error: "Sorry, it doesn't look like there's an audio player installed on your computer. Can you try installing MPV (https://mpv.io) or SoX?"
+      }
+    }
+
+    await this.loadMetadata()
+
+    this.player.on('printStatusLine', data => {
+      if (this.playingTrack) {
+        this.emit('printStatusLine', data)
+      }
+    })
+
+    return true
+  }
+
+
+  async readMetadata() {
+    try {
+      return JSON.parse(await readFile(this.metadataPath))
+    } catch (error) {
+      // Just stop. It's okay to fail to load metadata.
+      return null
+    }
+  }
+
+  async loadMetadata() {
+    Object.assign(this.metadataDictionary, await this.readMetadata())
+  }
+
+  async saveMetadata() {
+    const newData = Object.assign({}, await this.readMetadata(), this.metadataDictionary)
+    await writeFile(this.metadataPath, JSON.stringify(newData))
+  }
+
+  getMetadataFor(item) {
+    const key = this.metadataDictionary[item.downloaderArg]
+    return this.metadataDictionary[key] || null
+  }
+
+  async processMetadata(item, reprocess = false, top = true) {
+    let counter = 0
+
+    if (isGroup(item)) {
+      const results = await Promise.all(item.items.map(x => this.processMetadata(x, reprocess, false)))
+      counter += results.reduce((acc, n) => acc + n, 0)
+    } else process: {
+      if (!reprocess && this.getMetadataFor(item)) {
+        break process
+      }
+
+      await this.throttleMetadata(async () => {
+        const filePath = await this.download(item)
+        const metadataReader = getMetadataReaderFor(filePath)
+        const data = await metadataReader(filePath)
+
+        this.metadataDictionary[item.downloaderArg] = filePath
+        this.metadataDictionary[filePath] = data
+      })
+
+      this.emit('processMetadata progress', this.throttleMetadata.queue.length)
+
+      counter++
+    }
+
+    if (top) {
+      await this.saveMetadata()
+    }
+
+    return counter
+  }
+
+
+  getRecordFor(item) {
+    return this.recordStore.getRecord(item)
+  }
+
+
+  queue(topItem, afterItem = null, {movePlayingTrack = true} = {}) {
+    const { items } = this.queueGrouplike
+    const newTrackIndex = items.length
+
+    // The position which new tracks should be added at, if afterItem is
+    // passed.
+    const afterIndex = afterItem && items.indexOf(afterItem)
+
+    // Keeps track of how many tracks have been added; this is used so that
+    // a whole group can be queued in order after a given item.
+    let grouplikeOffset = 0
+
+    const recursivelyAddTracks = item => {
+      // For groups, just queue all children.
+      if (isGroup(item)) {
+        for (const child of item.items) {
+          recursivelyAddTracks(child)
+        }
+
+        return
+      }
+
+      // You can't put the same track in the queue twice - we automatically
+      // remove the old entry. (You can't for a variety of technical reasons,
+      // but basically you either have the display all bork'd, or new tracks
+      // can't be added to the queue in the right order (because Object.assign
+      // is needed to fix the display, but then you end up with a new object
+      // that doesn't work with indexOf).)
+      if (items.includes(item)) {
+        // HOWEVER, if the "moveCurrentTrack" option is false, and that item
+        // is the one that's currently playing, we won't do anything with it
+        // at all.
+        if (!movePlayingTrack && item === this.playingTrack) {
+          return
+        }
+        items.splice(items.indexOf(item), 1)
+      }
+
+      if (afterItem === 'FRONT') {
+        items.unshift(item)
+      } else if (afterItem) {
+        items.splice(afterIndex + 1 + grouplikeOffset, 0, item)
+      } else {
+        items.push(item)
+      }
+
+      grouplikeOffset++
+    }
+
+    recursivelyAddTracks(topItem)
+
+    // This is the first new track, if a group was queued.
+    const newTrack = items[newTrackIndex]
+
+    return newTrack
+  }
+
+  distributeQueue(grouplike, {how = 'evenly', rangeEnd = 'end-of-queue'}) {
+    if (isTrack(grouplike)) {
+      grouplike = {items: [grouplike]}
+    }
+
+    const { items } = this.queueGrouplike
+    const newItems = flattenGrouplike(grouplike).items
+
+    // Expressly do an initial pass and unqueue the items we want to queue -
+    // otherwise they would mess with the math we do afterwords.
+    for (const item of newItems) {
+      if (items.includes(item)) {
+        /*
+        if (!movePlayingTrack && item === this.playingTrack) {
+          // NB: if uncommenting this code, splice item from newItems and do
+          // continue instead of return!
+          return
+        }
+        */
+        items.splice(items.indexOf(item), 1)
+      }
+    }
+
+    const distributeStart = items.indexOf(this.playingTrack) + 1
+
+    let distributeEnd
+    if (rangeEnd === 'end-of-queue') {
+      distributeEnd = items.length
+    } else if (typeof rangeEnd === 'number') {
+      distributeEnd = Math.min(items.length, rangeEnd)
+    } else {
+      throw new Error('Invalid rangeEnd: ' + rangeEnd)
+    }
+
+    const distributeSize = distributeEnd - distributeStart
+
+    const queueItem = (item, insertIndex) => {
+      if (items.includes(item)) {
+        /*
+        if (!movePlayingTrack && item === this.playingTrack) {
+          return
+        }
+        */
+        items.splice(items.indexOf(item), 1)
+      } else {
+        offset++
+      }
+      items.splice(insertIndex, 0, item)
+    }
+
+    if (how === 'evenly') {
+      let offset = 0
+      for (const item of newItems) {
+        const insertIndex = distributeStart + Math.floor(offset)
+        items.splice(insertIndex, 0, item)
+        offset++
+        offset += distributeSize / newItems.length
+      }
+    } else if (how === 'randomly') {
+      const indexes = newItems.map(() => Math.floor(Math.random() * distributeSize))
+      indexes.sort()
+      for (let i = 0; i < newItems.length; i++) {
+        const item = newItems[i]
+        const insertIndex = distributeStart + indexes[i] + i
+        items.splice(insertIndex, 0, item)
+      }
+    }
+  }
+
+  unqueue(topItem, focusItem = null) {
+    // This function has support to unqueue groups - it removes all tracks in
+    // the group recursively. (You can never unqueue a group itself from the
+    // queue listing because groups can't be added directly to the queue.)
+
+    const { items } = this.queueGrouplike
+
+    const recursivelyUnqueueTracks = item => {
+      // For groups, just unqueue all children. (Groups themselves can't be
+      // added to the queue, so we don't need to worry about removing them.)
+      if (isGroup(item)) {
+        for (const child of item.items) {
+          recursivelyUnqueueTracks(child)
+        }
+
+        return
+      }
+
+      // Don't unqueue the currently-playing track - this usually causes more
+      // trouble than it's worth.
+      if (item === this.playingTrack) {
+        return
+      }
+
+      // If we're unqueueing the item which is currently focused by the cursor,
+      // just move the cursor ahead.
+      if (item === focusItem) {
+        focusItem = items[items.indexOf(focusItem) + 1]
+        // ...Unless that puts it at past the end of the list, in which case, move
+        // it behind the item we're removing.
+        if (!focusItem) {
+          focusItem = items[items.length - 2]
+        }
+      }
+
+      if (items.includes(item)) {
+        items.splice(items.indexOf(item), 1)
+      }
+    }
+
+    recursivelyUnqueueTracks(topItem)
+
+    return focusItem
+  }
+
+  playSooner(item) {
+    this.distributeQueue(item, {
+      how: 'randomly',
+      rangeEnd: this.queueGrouplike.items.indexOf(item)
+    })
+  }
+
+  playLater(item) {
+    this.skipIfCurrent(item)
+    this.distributeQueue(item, {
+      how: 'randomly'
+    })
+  }
+
+  skipIfCurrent(track) {
+    if (track === this.playingTrack) {
+      this.playNextTrack(track)
+    }
+  }
+
+  shuffleQueue() {
+    const queue = this.queueGrouplike
+    const index = queue.items.indexOf(this.playingTrack) + 1 // This is 0 if no track is playing
+    const initialItems = queue.items.slice(0, index)
+    const remainingItems = queue.items.slice(index)
+    const newItems = initialItems.concat(shuffleArray(remainingItems))
+    queue.items = newItems
+  }
+
+  clearQueue() {
+    // Clear the queue so that there aren't any items left in it (except for
+    // the track that's currently playing).
+    this.queueGrouplike.items = this.queueGrouplike.items
+      .filter(item => item === this.playingTrack)
+  }
+
+
+  seekAhead(seconds) {
+    this.player.seekAhead(seconds)
+  }
+
+  seekBack(seconds) {
+    this.player.seekBack(seconds)
+  }
+
+  togglePause() {
+    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)
+  }
+
+  volDown(amount = 10) {
+    this.player.volDown(amount)
+  }
+
+  setVolume(value) {
+    this.player.setVolume(value)
+  }
+
+  stopPlaying() {
+    // We emit this so playTrack doesn't immediately start a new track.
+    // We aren't *actually* about to play a new track.
+    this.emit('playing new track')
+    this.player.kill()
+    this.clearPlayingTrack()
+  }
+
+
+  async play(item) {
+    if (this.player === null) {
+      throw new Error('Attempted to play before a player was loaded')
+    }
+
+    let playingThisTrack = true
+    this.emit('playing new track')
+    this.once('playing new track', () => {
+      playingThisTrack = false
+    })
+
+    // If it's a group, play the first track.
+    if (isGroup(item)) {
+      item = flattenGrouplike(item).items[0]
+    }
+
+    // If there is no item (e.g. an empty group), well.. don't do anything.
+    if (!item) {
+      return
+    }
+
+    playTrack: {
+      // No downloader argument? That's no good - stop here.
+      // TODO: An error icon on this item, or something???
+      if (!item.downloaderArg) {
+        break playTrack
+      }
+
+      // If, by the time the track is downloaded, we're playing something
+      // different from when the download started, assume that we just want to
+      // keep listening to whatever new thing we started.
+
+      const oldTrack = this.playingTrack
+
+      const downloadFile = await this.download(item)
+
+      if (this.playingTrack !== oldTrack) {
+        return
+      }
+
+      this.playingTrack = item
+      this.emit('playing', this.playingTrack, oldTrack)
+
+      await this.player.kill()
+      await this.player.playFile(downloadFile)
+    }
+
+    // playingThisTrack now means whether the track played through to the end
+    // (true), or was stopped by a different track being started (false).
+
+    if (playingThisTrack) {
+      if (!this.playNext(item)) {
+        this.clearPlayingTrack()
+      }
+    }
+  }
+
+  playNext(track, automaticallyQueueNextTrack = false) {
+    if (!track) return false
+
+    const queue = this.queueGrouplike
+    let queueIndex = queue.items.indexOf(track)
+    if (queueIndex === -1) return false
+    queueIndex++
+
+    if (queueIndex >= queue.items.length) {
+      if (automaticallyQueueNextTrack) {
+        const parent = track[parentSymbol]
+        if (!parent) return false
+        const index = parent.items.indexOf(track)
+        const nextItem = parent.items[index + 1]
+        if (!nextItem) return false
+        this.queue(nextItem)
+        queueIndex = queue.items.length - 1
+      } else {
+        return false
+      }
+    }
+
+    this.play(queue.items[queueIndex])
+    return true
+  }
+
+  playPrevious(track, automaticallyQueuePreviousTrack = false) {
+    if (!track) return false
+
+    const queue = this.queueGrouplike
+    let queueIndex = queue.items.indexOf(track)
+    if (queueIndex === -1) return false
+    queueIndex--
+
+    if (queueIndex < 0) {
+      if (automaticallyQueuePreviousTrack) {
+        const parent = track[parentSymbol]
+        if (!parent) return false
+        const index = parent.items.indexOf(track)
+        const previousItem = parent.items[index - 1]
+        if (!previousItem) return false
+        this.queue(previousItem, 'FRONT')
+        queueIndex = 0
+      } else {
+        return false
+      }
+    }
+
+    this.play(queue.items[queueIndex])
+    return true
+  }
+
+  clearPlayingTrack() {
+    if (this.playingTrack !== null) {
+      const oldTrack = this.playingTrack
+      this.playingTrack = null
+      this.emit('playing', null, oldTrack)
+    }
+  }
+
+  async download(item) {
+    if (isGroup(item)) {
+      // TODO: Download all children (recursively), show a confirmation prompt
+      // if there are a lot of items (remember to flatten).
+      return
+    }
+
+    // Don't start downloading an item if we're already downloading it!
+    if (this.getRecordFor(item).downloading) {
+      return
+    }
+
+    const arg = item.downloaderArg
+    this.getRecordFor(item).downloading = true
+    try {
+      return await getDownloaderFor(arg)(arg)
+    } finally {
+      this.getRecordFor(item).downloading = false
+    }
+  }
+}
+
+module.exports = Backend
diff --git a/index.js b/index.js
index 578fdc3..23d0449 100755
--- a/index.js
+++ b/index.js
@@ -2,19 +2,31 @@
 
 // omg I am tired of code
 
-const { AppElement } = require('./ui')
-const { updatePlaylistFormat } = require('./playlist-utils')
 const { getAllCrawlersForArg } = require('./crawlers')
-const fs = require('fs')
-const util = require('util')
+const AppElement = require('./ui')
+const Backend = require('./backend')
 const processSmartPlaylist = require('./smart-playlist')
-const ansi = require('./tui-lib/util/ansi')
-const CommandLineInterfacer = require('./tui-lib/util/CommandLineInterfacer')
-const EventEmitter = require('events')
-const Flushable = require('./tui-lib/util/Flushable')
-const Root = require('./tui-lib/ui/Root')
 
-const readFile = util.promisify(fs.readFile)
+const {
+  getItemPathString,
+  updatePlaylistFormat
+} = require('./playlist-utils')
+
+const {
+  ui: {
+    Root
+  },
+  util: {
+    ansi,
+    CommandLineInterfacer,
+    Flushable
+  }
+} = require('./tui-lib')
+
+const { promisify } = require('util')
+const fs = require('fs')
+const readFile = promisify(fs.readFile)
+const writeFile = promisify(fs.writeFile)
 
 // Hack to get around errors when piping many things to stdout/err
 // (from general-util promisifyProcess)
@@ -40,11 +52,22 @@ async function main() {
 
   const root = new Root(interfacer)
 
-  const appElement = new AppElement()
+  const backend = new Backend()
+
+  backend.on('playing', track => {
+    if (track) {
+      writeFile(backend.rootDirectory + '/current-track.txt',
+        getItemPathString(track))
+      writeFile(backend.rootDirectory + '/current-track.json',
+        JSON.stringify(track, null, 2))
+    }
+  })
+
+  const appElement = new AppElement(backend)
   root.addChild(appElement)
   root.select(appElement)
 
-  const result = await appElement.setup()
+  const result = await backend.setup()
 
   if (result.error) {
     console.error(result.error)
diff --git a/ui.js b/ui.js
index 834269e..c2c3e67 100644
--- a/ui.js
+++ b/ui.js
@@ -1,12 +1,27 @@
+// The UI in MTUI! Interfaces with the backend to form the complete mtui app.
+
+'use strict'
+
 const { getAllCrawlersForArg } = require('./crawlers')
-const { getMetadataReaderFor } = require('./metadata-readers')
-const { getDownloaderFor } = require('./downloaders')
-const { getPlayer } = require('./players')
-const { parentSymbol, isGroup, isTrack, getItemPath, getItemPathString, flattenGrouplike, countTotalItems, shuffleOrderOfGroups, cloneGrouplike, getNameWithoutTrackNumber } = require('./playlist-utils')
-const { shuffleArray, throttlePromise, getTimeStringsFromSec } = require('./general-util')
 const processSmartPlaylist = require('./smart-playlist')
 const UndoManager = require('./undo-manager')
-const RecordStore = require('./record-store')
+
+const {
+  shuffleArray,
+  getTimeStringsFromSec
+} = require('./general-util')
+
+const {
+  cloneGrouplike,
+  countTotalItems,
+  flattenGrouplike,
+  getItemPath,
+  getNameWithoutTrackNumber,
+  isGroup,
+  isTrack,
+  parentSymbol,
+  shuffleOrderOfGroups
+} = require('./playlist-utils')
 
 const {
   ui: {
@@ -30,11 +45,6 @@ const {
   }
 } = require('./tui-lib')
 
-const fs = require('fs')
-const { promisify } = require('util')
-const readFile = promisify(fs.readFile)
-const writeFile = promisify(fs.writeFile)
-
 const input = {}
 
 const keyBindings = [
@@ -124,23 +134,37 @@ telc.isSelect = input.isSelect
 telc.isBackspace = input.isBackspace
 
 class AppElement extends FocusElement {
-  constructor() {
+  constructor(backend) {
     super()
 
-    this.player = null
-    this.recordStore = new RecordStore()
+    this.backend = backend
+
+    this.backend.on('playing', (track, oldTrack) => {
+      if (track) {
+        this.playbackInfoElement.updateTrack(track)
+        if (this.queueListingElement.currentItem === oldTrack) {
+          this.queueListingElement.selectAndShow(track)
+        }
+      } else {
+        this.playbackInfoElement.clearInfo()
+      }
+      this.updateQueueLengthLabel()
+    })
+
+    this.backend.on('printStatusLine', data => {
+      this.playbackInfoElement.updateProgress(data, this.backend.player)
+      this.updateQueueLengthLabel()
+    })
+
+    this.backend.on('processMetadata progress', remaining => {
+      this.metadataStatusLabel.text = `Processing metadata - ${remaining} to go.`
+    })
+
+    // TODO: Move edit mode stuff to the backend!
     this.undoManager = new UndoManager()
-    this.throttleMetadata = throttlePromise(10)
-    this.metadataDictionary = {}
-    this.queueGrouplike = {name: 'Queue', isTheQueue: true, items: []}
     this.markGrouplike = {name: 'Marked', items: []}
     this.editMode = false
 
-    this.rootDirectory = process.env.HOME + '/.mtui'
-    this.metadataPath = this.rootDirectory + '/track-metadata.json'
-
-    this.loadMetadata()
-
     // We add this is a child later (so that it's on top of every element).
     this.menu = new ContextMenu()
 
@@ -164,7 +188,7 @@ class AppElement extends FocusElement {
 
     this.queueListingElement = new QueueListingElement(this)
     this.setupCommonGrouplikeListingEvents(this.queueListingElement)
-    this.queueListingElement.loadGrouplike(this.queueGrouplike)
+    this.queueListingElement.loadGrouplike(this.backend.queueGrouplike)
     this.paneRight.addChild(this.queueListingElement)
 
     this.queueLengthLabel = new Label('')
@@ -173,8 +197,8 @@ class AppElement extends FocusElement {
     this.queueTimeLabel = new Label('')
     this.paneRight.addChild(this.queueTimeLabel)
 
-    this.queueListingElement.on('queue', item => this.playGrouplikeItem(item))
-    this.queueListingElement.on('remove', item => this.unqueueGrouplikeItem(item))
+    this.queueListingElement.on('queue', item => this.play(item))
+    this.queueListingElement.on('remove', item => this.unqueue(item))
     this.queueListingElement.on('shuffle', () => this.shuffleQueue())
     this.queueListingElement.on('clear', () => this.clearQueue())
     this.queueListingElement.on('select main listing',
@@ -226,25 +250,26 @@ class AppElement extends FocusElement {
         {label: 'Suspend', action: () => this.suspend()}
       ]},
       {text: 'Playback', menuFn: () => {
-        const { items } = this.queueGrouplike
-        const curIndex = items.indexOf(this.playingTrack)
+        const { playingTrack } = this.backend
+        const { items } = this.backend.queueGrouplike
+        const curIndex = items.indexOf(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.)'},
+          {label: playingTrack ? `("${playingTrack.name}")` : '(No track playing.)'},
           {divider: true},
-          this.playingTrack && {element: this.playingControl},
+          playingTrack && {element: this.playingControl},
           {element: this.loopingControl},
           {element: this.volumeSlider},
           (next || previous) && {divider: true},
-          previous && {label: `Previous (${previous.name})`, action: () => this.playPreviousTrack(this.playingTrack)},
-          next && {label: `Next (${next.name})`, action: () => this.playNextTrack(this.playingTrack)},
+          previous && {label: `Previous (${previous.name})`, action: () => this.backend.playPrevious(playingTrack)},
+          next && {label: `Next (${next.name})`, action: () => this.backend.playNext(playingTrack)},
           next && {label: '- Play later', action: () => this.playLater(next)}
         ]
       }},
       {text: 'Queue', menuFn: () => {
-        const { items } = this.queueGrouplike
+        const { items } = this.backend.queueGrouplike
         const curIndex = items.indexOf(this.playingTrack)
 
         return [
@@ -257,18 +282,18 @@ class AppElement extends FocusElement {
     ])
 
     this.playingControl = new ToggleControl('Pause?', {
-      setValue: val => this.setPause(val),
-      getValue: () => this.player.isPaused
+      setValue: val => this.backend.setPause(val),
+      getValue: () => this.backend.player.isPaused
     })
 
     this.loopingControl = new ToggleControl('Loop current track?', {
-      setValue: val => this.setLoop(val),
-      getValue: () => this.player.isLooping
+      setValue: val => this.backend.setLoop(val),
+      getValue: () => this.backend.player.isLooping
     })
 
     this.volumeSlider = new SliderElement('Volume', {
-      setValue: val => this.setVolume(val),
-      getValue: () => this.player.volume
+      setValue: val => this.backend.setVolume(val),
+      getValue: () => this.backend.player.volume
     })
   }
 
@@ -391,40 +416,37 @@ class AppElement extends FocusElement {
     }
   }
 
-  playSooner(item) {
-    this.distributeQueueGrouplikeItem(item, {
-      how: 'randomly',
-      rangeEnd: this.queueGrouplike.items.indexOf(item)
-    })
+  play(item) {
+    this.backend.play(item)
+  }
+
+  unqueue(item) {
+    let focusItem = this.queueListingElement.currentItem
+    focusItem = this.backend.unqueue(item, focusItem)
+
+    this.queueListingElement.buildItems()
+    this.updateQueueLengthLabel()
 
-    // It may not have queued as soon as the user wants; in that acse, they'll
+    if (focusItem) {
+      this.queueListingElement.selectAndShow(focusItem)
+    }
+  }
+
+  playSooner(item) {
+    this.backend.playSooner(item)
+    // It may not have queued as soon as the user wants; in that case, they'll
     // want to queue it sooner again. Automatically reselect the track so that
     // this they don't have to navigate back to it by hand.
     this.queueListingElement.selectAndShow(item)
   }
 
   playLater(item) {
-    this.handleQueueOptions(item, {
-      where: 'distribute-randomly',
-      skip: true
-    })
-
+    this.backend.playLater(item)
     // Just for consistency with playSooner (you can press ^-L to quickly get
     // back to the current track).
     this.queueListingElement.selectAndShow(item)
   }
 
-  playLater(item) {
-    this.handleQueueOptions(item, {
-      where: 'distribute-randomly',
-      skip: true
-    })
-
-    // Just for consistency.
-    this.queueListingElement.selectAndShow(item)
-  }
-
-
   showMenuForItemElement(el, listing) {
     const emitControls = play => () => {
       this.handleQueueOptions(item, {
@@ -445,7 +467,7 @@ class AppElement extends FocusElement {
         {divider: true},
         {label: 'Play later', action: () => this.playLater(item)},
         {label: 'Play sooner', action: () => this.playSooner(item)},
-        {label: 'Remove from queue', action: () => this.unqueueGrouplikeItem(item)}
+        {label: 'Remove from queue', action: () => this.unqueue(item)}
       ]
     } else {
       items = [
@@ -482,7 +504,7 @@ class AppElement extends FocusElement {
 
         {label: 'Process metadata (new entries)', action: () => this.processMetadata(item, false)},
         {label: 'Process metadata (reprocess)', action: () => this.processMetadata(item, true)},
-        {label: 'Remove from queue', action: () => this.unqueueGrouplikeItem(item)}
+        {label: 'Remove from queue', action: () => this.unqueue(item)}
       ]
     }
 
@@ -548,27 +570,8 @@ class AppElement extends FocusElement {
     })
   }
 
-  async setup() {
-    this.player = await getPlayer()
-
-    if (!this.player) {
-      return {
-        error: "Sorry, it doesn't look like there's an audio player installed on your computer. Can you try installing MPV (https://mpv.io) or SoX?"
-      }
-    }
-
-    this.player.on('printStatusLine', data => {
-      if (this.playingTrack) {
-        this.playbackInfoElement.updateProgress(data, this.player)
-        this.updateQueueLengthLabel()
-      }
-    })
-
-    return true
-  }
-
   async shutdown() {
-    await this.player.kill()
+    await this.backend.stopPlaying()
     this.emit('quitRequested')
   }
 
@@ -624,23 +627,23 @@ class AppElement extends FocusElement {
     } else if ((telc.isLeft(keyBuf) || telc.isRight(keyBuf)) && this.menubar.isSelected) {
       return // le sigh
     } else if (input.isRight(keyBuf)) {
-      this.seekAhead(10)
+      this.backend.seekAhead(10)
     } else if (input.isLeft(keyBuf)) {
-      this.seekBack(10)
+      this.backend.seekBack(10)
     } else if (input.isTogglePause(keyBuf)) {
-      this.togglePause()
+      this.backend.togglePause()
     } else if (input.isToggleLoop(keyBuf)) {
-      this.toggleLoop()
+      this.backend.toggleLoop()
     } else if (input.isVolumeUp(keyBuf)) {
-      this.volUp()
+      this.backend.volUp()
     } else if (input.isVolumeDown(keyBuf)) {
-      this.volDown()
+      this.backend.volDown()
     } else if (input.isStop(keyBuf)) {
-      this.clearPlayingTrack()
+      this.backend.stopPlaying()
     } else if (input.isSkipBack(keyBuf)) {
-      this.playPreviousTrack(this.playingTrack, true)
+      this.backend.playPrevious(this.backend.playingTrack, true)
     } else if (input.isSkipAhead(keyBuf)) {
-      this.playNextTrack(this.playingTrack, true)
+      this.backend.playNext(this.backend.playingTrack, true)
     } else if (input.isFocusTabber(keyBuf) && this.tabber.selectable) {
       this.root.select(this.tabber)
     } else if (input.isFocusQueue(keyBuf) && this.queueListingElement.selectable) {
@@ -704,20 +707,12 @@ class AppElement extends FocusElement {
   }
 
   shuffleQueue() {
-    const queue = this.queueGrouplike
-    const index = queue.items.indexOf(this.playingTrack) + 1 // This is 0 if no track is playing
-    const initialItems = queue.items.slice(0, index)
-    const remainingItems = queue.items.slice(index)
-    const newItems = initialItems.concat(shuffleArray(remainingItems))
-    queue.items = newItems
+    this.backend.shuffleQueue()
     this.queueListingElement.buildItems()
   }
 
   clearQueue() {
-    // Clear the queue so that there aren't any items left in it (except for
-    // the track that's currently playing).
-    this.queueGrouplike.items = this.queueGrouplike.items
-      .filter(item => item === this.playingTrack)
+    this.backend.clearQueue()
     this.queueListingElement.buildItems()
     this.queueListingElement.selectNone()
     this.updateQueueLengthLabel()
@@ -727,57 +722,16 @@ class AppElement extends FocusElement {
     }
   }
 
-  seekAhead(seconds) {
-    this.player.seekAhead(seconds)
-  }
-
-  seekBack(seconds) {
-    this.player.seekBack(seconds)
-  }
-
-  togglePause() {
-    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)
-  }
-
-  volDown(amount = 10) {
-    this.player.volDown(amount)
-  }
-
-  setVolume(value) {
-    this.player.setVolume(value)
-  }
-
-  stopPlaying() {
-    // We emit this so playTrack doesn't immediately start a new track.
-    // We aren't *actually* about to play a new track.
-    this.emit('playing new track')
-    this.player.kill()
-  }
-
   // TODO: I'd like to name/incorporate this function better.. for now it's
   // just directly moved from the old event listener on grouplikeListings for
   // 'queue'.
   handleQueueOptions(item, {where = 'end', order = 'normal', play = false, skip = false} = {}) {
     const passedItem = item
 
-    if (skip && this.playingTrack === item) {
-      this.playNextTrack(this.playingTrack)
+    let { playingTrack } = this.backend
+
+    if (skip && playingTrack === item) {
+      this.backend.playNext(playingTrack)
     }
 
     if (isGroup(item)) {
@@ -794,391 +748,70 @@ class AppElement extends FocusElement {
     if (where === 'next' || where === 'next-selected' || where === 'end') {
       let afterItem = null
       if (where === 'next') {
-        afterItem = this.playingTrack
+        afterItem = playingTrack
       } else if (where === 'next-selected') {
         afterItem = this.queueListingElement.currentItem
       }
 
-      this.queueGrouplikeItem(item, afterItem, {
+      this.backend.queue(item, afterItem, {
         movePlayingTrack: order === 'normal'
       })
+      this.queueListingElement.buildItems()
 
       if (isTrack(passedItem)) {
         this.queueListingElement.selectAndShow(passedItem)
       }
     } else if (where.startsWith('distribute-')) {
-      this.distributeQueueGrouplikeItem(item, {
+      this.backend.distributeQueue(item, {
         how: where.slice('distribute-'.length)
       })
+      this.queueListingElement.buildItems()
     }
 
-    if (play) {
-      this.playGrouplikeItem(item)
-    }
-  }
-
-  queueGrouplikeItem(topItem, afterItem = null, {movePlayingTrack = true} = {}) {
-    const newTrackIndex = this.queueGrouplike.items.length
-
-    // The position which new tracks should be added at, if afterItem is
-    // passed.
-    const afterIndex = afterItem && this.queueGrouplike.items.indexOf(afterItem)
-
-    // Keeps track of how many tracks have been added; this is used so that
-    // a whole group can be queued in order after a given item.
-    let grouplikeOffset = 0
-
-    const recursivelyAddTracks = item => {
-      // For groups, just queue all children.
-      if (isGroup(item)) {
-        for (const child of item.items) {
-          recursivelyAddTracks(child)
-        }
-
-        return
-      }
-
-      const items = this.queueGrouplike.items
-
-      // You can't put the same track in the queue twice - we automatically
-      // remove the old entry. (You can't for a variety of technical reasons,
-      // but basically you either have the display all bork'd, or new tracks
-      // can't be added to the queue in the right order (because Object.assign
-      // is needed to fix the display, but then you end up with a new object
-      // that doesn't work with indexOf).)
-      if (items.includes(item)) {
-        // HOWEVER, if the "moveCurrentTrack" option is false, and that item
-        // is the one that's currently playing, we won't do anything with it
-        // at all.
-        if (!movePlayingTrack && item === this.playingTrack) {
-          return
-        }
-        items.splice(items.indexOf(item), 1)
-      }
-
-      if (afterItem === 'FRONT') {
-        items.unshift(item)
-      } else if (afterItem) {
-        items.splice(afterIndex + 1 + grouplikeOffset, 0, item)
-      } else {
-        items.push(item)
-      }
-
-      grouplikeOffset++
-    }
-
-    recursivelyAddTracks(topItem)
-    this.queueListingElement.buildItems()
-    this.updateQueueLengthLabel()
-
-    // This is the first new track, if a group was queued.
-    const newTrack = this.queueGrouplike.items[newTrackIndex]
-
-    return newTrack
-  }
-
-  distributeQueueGrouplikeItem(grouplike, {how = 'evenly', rangeEnd = 'end-of-queue'}) {
-    if (isTrack(grouplike)) {
-      grouplike = {items: [grouplike]}
-    }
-
-    const queue = this.queueGrouplike
-    const newItems = flattenGrouplike(grouplike).items
-
-    // Expressly do an initial pass and unqueue the items we want to queue -
-    // otherwise they would mess with the math we do afterwords.
-    for (const item of newItems) {
-      if (queue.items.includes(item)) {
-        /*
-        if (!movePlayingTrack && item === this.playingTrack) {
-          // NB: if uncommenting this code, splice item from newItems and do
-          // continue instead of return!
-          return
-        }
-        */
-        queue.items.splice(queue.items.indexOf(item), 1)
-      }
-    }
-
-    const distributeStart = queue.items.indexOf(this.playingTrack) + 1
-
-    let distributeEnd
-    if (rangeEnd === 'end-of-queue') {
-      distributeEnd = queue.items.length
-    } else if (typeof rangeEnd === 'number') {
-      distributeEnd = Math.min(queue.items.length, rangeEnd)
-    } else {
-      throw new Error('Invalid rangeEnd: ' + rangeEnd)
-    }
-
-    const distributeSize = distributeEnd - distributeStart
-
-    const queueItem = (item, insertIndex) => {
-      if (queue.items.includes(item)) {
-        /*
-        if (!movePlayingTrack && item === this.playingTrack) {
-          return
-        }
-        */
-        queue.items.splice(queue.items.indexOf(item), 1)
-      } else {
-        offset++
-      }
-      queue.items.splice(insertIndex, 0, item)
-    }
-
-    if (how === 'evenly') {
-      let offset = 0
-      for (const item of newItems) {
-        const insertIndex = distributeStart + Math.floor(offset)
-        queue.items.splice(insertIndex, 0, item)
-        offset++
-        offset += distributeSize / newItems.length
-      }
-    } else if (how === 'randomly') {
-      const indexes = newItems.map(() => Math.floor(Math.random() * distributeSize))
-      indexes.sort()
-      for (let i = 0; i < newItems.length; i++) {
-        const item = newItems[i]
-        const insertIndex = distributeStart + indexes[i] + i
-        queue.items.splice(insertIndex, 0, item)
-      }
-    }
-
-    this.queueListingElement.buildItems()
     this.updateQueueLengthLabel()
-  }
-
-  unqueueGrouplikeItem(topItem) {
-    // This function has support to unqueue groups - it removes all tracks in
-    // the group recursively. (You can never unqueue a group itself from the
-    // queue listing because groups can't be added directly to the queue.)
-
-    const recursivelyUnqueueTracks = item => {
-      // For groups, just unqueue all children. (Groups themselves can't be
-      // added to the queue, so we don't need to worry about removing them.)
-      if (isGroup(item)) {
-        for (const child of item.items) {
-          recursivelyUnqueueTracks(child)
-        }
-
-        return
-      }
-
-      // Don't unqueue the currently-playing track - this usually causes more
-      // trouble than it's worth.
-      if (item === this.playingTrack) {
-        return
-      }
-
-      const items = this.queueGrouplike.items
-
-      // If we're unqueueing the item which is currently focused by the cursor,
-      // just move the cursor ahead.
-      if (item === focusItem) {
-        focusItem = items[items.indexOf(focusItem) + 1]
-        // ...Unless that puts it at past the end of the list, in which case, move
-        // it behind the item we're removing.
-        if (!focusItem) {
-          focusItem = items[items.length - 2]
-        }
-      }
-
-      if (items.includes(item)) {
-        items.splice(items.indexOf(item), 1)
-      }
-    }
-
-    let focusItem = this.queueListingElement.currentItem
-
-    recursivelyUnqueueTracks(topItem)
-    this.queueListingElement.buildItems()
-    this.updateQueueLengthLabel()
-
-    if (focusItem) {
-      this.queueListingElement.selectAndShow(focusItem)
-    }
-  }
-
-  async downloadGrouplikeItem(item) {
-    if (isGroup(item)) {
-      // TODO: Download all children (recursively), show a confirmation prompt
-      // if there are a lot of items (remember to flatten).
-      return
-    }
-
-    // Don't start downloading an item if we're already downloading it!
-    if (this.recordStore.getRecord(item).downloading) {
-      return
-    }
-
-    const arg = item.downloaderArg
-    this.recordStore.getRecord(item).downloading = true
-    try {
-      return await getDownloaderFor(arg)(arg)
-    } finally {
-      this.recordStore.getRecord(item).downloading = false
-    }
-  }
-
-  async playGrouplikeItem(item) {
-    if (this.player === null) {
-      throw new Error('Attempted to play before a player was loaded')
-    }
-
-    let playingThisTrack = true
-    this.emit('playing new track')
-    this.once('playing new track', () => {
-      playingThisTrack = false
-    })
-
-    // If it's a group, play the first track.
-    if (isGroup(item)) {
-      item = flattenGrouplike(item).items[0]
-    }
-
-    // If there is no item (e.g. an empty group), well.. don't do anything.
-    if (!item) {
-      return
-    }
-
-    playTrack: {
-      // No downloader argument? That's no good - stop here.
-      // TODO: An error icon on this item, or something???
-      if (!item.downloaderArg) {
-        break playTrack
-      }
-
-      // If, by the time the track is downloaded, we're playing something
-      // different from when the download started, assume that we just want to
-      // keep listening to whatever new thing we started.
-
-      const oldTrack = this.playingTrack
-
-      const downloadFile = await this.downloadGrouplikeItem(item)
-
-      if (this.playingTrack !== oldTrack) {
-        return
-      }
-
-      await this.player.kill()
-      this.playingTrack = item
-      this.playbackInfoElement.updateTrack(item)
-      this.updateQueueLengthLabel()
-
-      if (this.queueListingElement.currentItem === oldTrack) {
-        this.queueListingElement.selectAndShow(item)
-      }
-
-      await Promise.all([
-        writeFile(this.rootDirectory + '/current-track.txt',
-          getItemPathString(item)),
-        writeFile(this.rootDirectory + '/current-track.json',
-          JSON.stringify(item, null, 2))
-      ])
-
-      await this.player.playFile(downloadFile)
-    }
-
-    // playingThisTrack now means whether the track played through to the end
-    // (true), or was stopped by a different track being started (false).
-
-    if (playingThisTrack) {
-      if (!this.playNextTrack(item)) {
-        this.clearPlayingTrack()
-      }
-    }
-  }
-
-  playNextTrack(track, automaticallyQueueNextTrack = false) {
-    if (!track) {
-      return false
-    }
-
-    const queue = this.queueGrouplike
-    let queueIndex = queue.items.indexOf(track)
-    if (queueIndex === -1) {
-      return false
-    }
-    queueIndex++
 
-    if (queueIndex >= queue.items.length) {
-      if (automaticallyQueueNextTrack) {
-        const parent = track[parentSymbol]
-        if (!parent) {
-          return false
-        }
-        const index = parent.items.indexOf(track)
-        const nextItem = parent.items[index + 1]
-        if (!nextItem) {
-          return false
-        }
-        this.queueGrouplikeItem(nextItem, false)
-        queueIndex = queue.items.length - 1
-      } else {
-        return false
-      }
+    if (play) {
+      this.play(item)
     }
-
-    this.playGrouplikeItem(queue.items[queueIndex])
-    return true
   }
 
-  playPreviousTrack(track, automaticallyQueuePreviousTrack = false) {
-    if (!track) {
-      return false
+  async processMetadata(item) {
+    if (this.clearMetadataStatusTimeout) {
+      clearTimeout(this.clearMetadataStatusTimeout)
     }
 
-    const queue = this.queueGrouplike
-    let queueIndex = queue.items.indexOf(track)
-    if (queueIndex === -1) {
-      return false
-    }
-    queueIndex--
+    this.metadataStatusLabel.text = 'Processing metadata...'
+    this.metadataStatusLabel.visible = true
+    this.fixLayout()
 
-    if (queueIndex < 0) {
-      if (automaticallyQueuePreviousTrack) {
-        const parent = track[parentSymbol]
-        if (!parent) {
-          return false
-        }
-        const index = parent.items.indexOf(track)
-        const previousItem = parent.items[index - 1]
-        if (!previousItem) {
-          return false
-        }
-        this.queueGrouplikeItem(previousItem, 'FRONT')
-        queueIndex = 0
-      } else {
-        return false
-      }
-    }
+    const counter = await this.backend.processMetadata(item)
 
-    this.playGrouplikeItem(queue.items[queueIndex])
-    return true
-  }
+    const tracksMsg = (counter === 1) ? '1 track' : `${counter} tracks`
+    this.metadataStatusLabel.text = `Done processing metadata of ${tracksMsg}!`
 
-  clearPlayingTrack() {
-    this.playingTrack = null
-    this.stopPlaying()
-    this.playbackInfoElement.clearInfo()
-    this.updateQueueLengthLabel()
+    this.clearMetadataStatusTimeout = setTimeout(() => {
+      this.clearMetadataStatusTimeout = null
+      this.metadataStatusLabel.text = ''
+      this.metadataStatusLabel.visible = false
+      this.fixLayout()
+    }, 3000)
   }
 
   updateQueueLengthLabel() {
-    const { items } = this.queueGrouplike
+    const { playingTrack } = this.backend
+    const { items } = this.backend.queueGrouplike
 
     let noticedMissingMetadata = false
 
     const durationFn = (acc, track) => {
-      const metadata = this.getMetadataFor(track)
+      const metadata = this.backend.getMetadataFor(track)
       if (!metadata) noticedMissingMetadata = true
       return acc + (metadata && metadata.duration) || 0
     }
 
     let trackRemainSec = 0
 
-    if (this.playingTrack) {
+    if (playingTrack) {
       const { curSecTotal = 0, lenSecTotal = 0 } = this.playbackInfoElement.timeData
       trackRemainSec = lenSecTotal - curSecTotal
     }
@@ -1190,8 +823,8 @@ class AppElement extends FocusElement {
     // info element).
     let index = 0
 
-    if (this.playingTrack) {
-      index = items.indexOf(this.playingTrack)
+    if (playingTrack) {
+      index = items.indexOf(playingTrack)
       // If it's NOT counted by the playback info element's time data yet,
       // we skip this - the current track is counted as "ahead" and its
       // duration will be tallied like the rest of the "ahead" tracks.
@@ -1206,7 +839,7 @@ class AppElement extends FocusElement {
 
     const { duration } = getTimeStringsFromSec(0, totalRemainSec)
 
-    this.queueLengthLabel.text = (this.playingTrack && items.includes(this.playingTrack)
+    this.queueLengthLabel.text = (playingTrack && items.includes(playingTrack)
       ? `(${this.playSymbol} ${index} / ${items.length})`
       : `(${items.length})`)
 
@@ -1219,66 +852,10 @@ class AppElement extends FocusElement {
     this.queueTimeLabel.y = this.paneRight.contentH - 1
   }
 
-  async readMetadata() {
-    try {
-      return JSON.parse(await readFile(this.metadataPath))
-    } catch (error) {
-      // Just stop. It's okay to fail to load metadata.
-      return null
-    }
-  }
-
-  async loadMetadata() {
-    Object.assign(this.metadataDictionary, await this.readMetadata())
-  }
-
-  async saveMetadata() {
-    const newData = Object.assign({}, await this.readMetadata(), this.metadataDictionary)
-    await writeFile(this.metadataPath, JSON.stringify(newData))
-  }
-
-  async processMetadata(item, reprocess = false, top = true) {
-    if (top) {
-      this.metadataStatusLabel.text = 'Processing metadata...'
-      this.metadataStatusLabel.visible = true
-      this.fixLayout()
-    }
-
-    if (isGroup(item)) {
-      await Promise.all(item.items.map(x => this.processMetadata(x, reprocess, false)))
-    } else process: {
-      if (!reprocess && this.getMetadataFor(item)) {
-        break process
-      }
-
-      await this.throttleMetadata(async () => {
-        const filePath = await this.downloadGrouplikeItem(item)
-        const metadataReader = getMetadataReaderFor(filePath)
-        const data = await metadataReader(filePath)
-
-        this.metadataDictionary[item.downloaderArg] = filePath
-        this.metadataDictionary[filePath] = data
-      })
-
-      this.metadataStatusLabel.text = `Processing metadata - ${this.throttleMetadata.queue.length} to go.`
-    }
-
-    if (top) {
-      this.metadataStatusLabel.text = ''
-      this.metadataStatusLabel.visible = false
-      this.fixLayout()
-      await this.saveMetadata()
-    }
-  }
-
-  getMetadataFor(item) {
-    const key = this.metadataDictionary[item.downloaderArg]
-    return this.metadataDictionary[key] || null
-  }
-
   get playSymbol() {
-    if (this.player && this.playingTrack) {
-      if (this.player.isPaused) {
+    const { player, playingTrack } = this.backend
+    if (player && playingTrack) {
+      if (player.isPaused) {
         return '⏸'
       } else {
         return '▶'
@@ -1403,7 +980,7 @@ class GrouplikeListingElement extends Form {
     } else if (telc.isCharacter(keyBuf, 'G')) {
       this.form.selectAndShow(this.grouplike.items[this.grouplike.items.length - 1])
     } else if (keyBuf[0] === 12 && this.grouplike.isTheQueue) { // ctrl-L
-      this.form.selectAndShow(this.app.playingTrack)
+      this.form.selectAndShow(this.app.backend.playingTrack)
     } else {
       return super.keyPressed(keyBuf)
     }
@@ -2087,7 +1664,7 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
 
   drawTo(writable) {
     if (!this.hideMetadata) {
-      const metadata = this.app.getMetadataFor(this.item)
+      const metadata = this.app.backend.getMetadataFor(this.item)
       if (metadata) {
         const durationString = getTimeStringsFromSec(0, metadata.duration).duration
         this.rightText = ` (${durationString}) `
@@ -2149,7 +1726,7 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
     const braille = '⠈⠐⠠⠄⠂⠁'
     const brailleChar = braille[Math.floor(Date.now() / 250) % 6]
 
-    const record = this.app.recordStore.getRecord(this.item)
+    const record = this.app.backend.getRecordFor(this.item)
 
     if (this.isMarked) {
       writable.write('M')
@@ -2161,7 +1738,7 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
       writable.write('G')
     } else if (record.downloading) {
       writable.write(braille[Math.floor(Date.now() / 250) % 6])
-    } else if (this.app.playingTrack === this.item) {
+    } else if (this.app.backend.playingTrack === this.item) {
       writable.write('\u25B6')
     } else {
       writable.write(' ')
@@ -3071,4 +2648,4 @@ class Menubar extends ListScrollForm {
   }
 }
 
-module.exports.AppElement = AppElement
+module.exports = AppElement