« 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--index.js22
-rw-r--r--playlist-utils.js482
-rw-r--r--ui.js73
3 files changed, 565 insertions, 12 deletions
diff --git a/index.js b/index.js
index a86918a..635426e 100644
--- a/index.js
+++ b/index.js
@@ -9,6 +9,11 @@ const EventEmitter = require('events')
 const Flushable = require('./tui-lib/util/Flushable')
 const Root = require('./tui-lib/ui/Root')
 
+process.on('unhandledRejection', error => {
+  console.error(error.stack)
+  process.exit(1)
+})
+
 class InternalApp extends EventEmitter {
   constructor() {
     super()
@@ -45,10 +50,10 @@ class InternalApp extends EventEmitter {
 }
 
 async function main() {
+  /*
   const internalApp = new InternalApp()
   await internalApp.setup()
 
-  /*
   await internalApp.startPlaying('http://billwurtz.com/cable-television.mp3')
   await new Promise(r => setTimeout(r, 2000))
   internalApp.togglePause()
@@ -82,6 +87,8 @@ async function main() {
   root.addChild(appElement)
   root.select(appElement)
 
+  await appElement.setup()
+
   appElement.on('quitRequested', () => {
     process.stdout.write(ansi.cleanCursor())
     process.exit(0)
@@ -89,14 +96,17 @@ async function main() {
 
   const grouplike = {
     items: [
-      {name: 'Nice'},
-      {name: 'W00T!'},
-      {name: 'All-star'}
+      {name: 'alphabet shuffle', downloaderArg: 'http://www.billwurtz.com/alphabet-shuffle.mp3'},
+      {name: 'in california', downloaderArg: 'http://www.billwurtz.com/in-california.mp3'},
+      {name: 'i love you', downloaderArg: 'http://www.billwurtz.com/i-love-you.mp3'},
+      {name: 'movie star', downloaderArg: 'http://www.billwurtz.com/movie-star.mp3'},
+      {name: 'got to know what\'s going on', downloaderArg: 'http://www.billwurtz.com/got-to-know-whats-going-on.mp3'},
+      {name: 'outside', downloaderArg: 'http://www.billwurtz.com/outside.mp3'},
+      {name: 'La de da de da de da de day oh', downloaderArg: 'http://www.billwurtz.com/la-de-da-de-da-de-da-de-day-oh.mp3'},
+      {name: 'and the day goes on', downloaderArg: 'http://www.billwurtz.com/and-the-day-goes-on.mp3'}
     ]
   }
 
-  appElement.recordStore.getRecord(grouplike.items[2]).downloading = true
-
   appElement.grouplikeListingElement.loadGrouplike(grouplike)
 
   root.select(appElement.grouplikeListingElement)
diff --git a/playlist-utils.js b/playlist-utils.js
new file mode 100644
index 0000000..654d6ad
--- /dev/null
+++ b/playlist-utils.js
@@ -0,0 +1,482 @@
+'use strict'
+
+const path = require('path')
+const fs = require('fs')
+
+const { promisify } = require('util')
+const unlink = promisify(fs.unlink)
+
+const parentSymbol = Symbol('Parent group')
+
+function updatePlaylistFormat(playlist) {
+  const defaultPlaylist = {
+    options: [],
+    items: []
+  }
+
+  let playlistObj = {}
+
+  // Playlists can be in two formats...
+  if (Array.isArray(playlist)) {
+    // ..the first, a simple array of tracks and groups;
+
+    playlistObj = {items: playlist}
+  } else {
+    // ..or an object including metadata and configuration as well as the
+    // array described in the first.
+
+    playlistObj = playlist
+
+    // The 'tracks' property was used for a while, but it doesn't really make
+    // sense, since we also store groups in the 'tracks' property. So it was
+    // renamed to 'items'.
+    if ('tracks' in playlistObj) {
+      playlistObj.items = playlistObj.tracks
+      delete playlistObj.tracks
+    }
+  }
+
+  const fullPlaylistObj = Object.assign(defaultPlaylist, playlistObj)
+
+  return updateGroupFormat(fullPlaylistObj)
+}
+
+function updateGroupFormat(group) {
+  const defaultGroup = {
+    name: '',
+    items: []
+  }
+
+  let groupObj = {}
+
+  if (Array.isArray(group[1])) {
+    groupObj = {name: group[0], items: group[1]}
+  } else {
+    groupObj = group
+  }
+
+  groupObj = Object.assign(defaultGroup, groupObj)
+
+  groupObj.items = groupObj.items.map(item => {
+    // Check if it's a group; if not, it's probably a track.
+    if (typeof item[1] === 'array' || item.items) {
+      item = updateGroupFormat(item)
+    } else {
+      item = updateTrackFormat(item)
+
+      // TODO: Should this also apply to groups? Is recursion good? Probably
+      // not!
+      //
+      // TODO: How should saving/serializing handle this? For now it just saves
+      // the result, after applying. (I.e., "apply": {"foo": "baz"} will save
+      // child tracks with {"foo": "baz"}.)
+      if (groupObj.apply) {
+        Object.assign(item, groupObj.apply)
+      }
+    }
+
+    item[parentSymbol] = groupObj
+
+    return item
+  })
+
+  return groupObj
+}
+
+function updateTrackFormat(track) {
+  const defaultTrack = {
+    name: '',
+    downloaderArg: ''
+  }
+
+  let trackObj = {}
+
+  if (Array.isArray(track)) {
+    if (track.length === 2) {
+      trackObj = {name: track[0], downloaderArg: track[1]}
+    } else {
+      throw new Error("Unexpected non-length 2 array-format track")
+    }
+  } else {
+    trackObj = track
+  }
+
+  return Object.assign(defaultTrack, trackObj)
+}
+
+function filterTracks(grouplike, handleTrack) {
+  // Recursively filters every track in the passed grouplike. The track-handler
+  // function passed should either return true (to keep a track) or false (to
+  // remove the track). After tracks are filtered, groups which contain no
+  // items are removed.
+
+  if (typeof handleTrack !== 'function') {
+    throw new Error("Missing track handler function")
+  }
+
+  return Object.assign({}, grouplike, {
+    items: grouplike.items.filter(item => {
+      if (isTrack(item)) {
+        return handleTrack(item)
+      } else {
+        return true
+      }
+    }).map(item => {
+      if (isGroup(item)) {
+        return filterTracks(item, handleTrack)
+      } else {
+        return item
+      }
+    }).filter(item => {
+      if (isGroup(item)) {
+        return item.items.length > 0
+      } else {
+        return true
+      }
+    })
+  })
+}
+
+function flattenGrouplike(grouplike) {
+  // Flattens a group-like, taking all of the non-group items (tracks) at all
+  // levels in the group tree and returns them as a new group containing those
+  // tracks.
+
+  return {
+    items: grouplike.items.map(item => {
+      if (isGroup(item)) {
+        return flattenGrouplike(item).items
+      } else {
+        return [item]
+      }
+    }).reduce((a, b) => a.concat(b), [])
+  }
+}
+
+function collectGrouplikeChildren(grouplike, filter = null) {
+  // Collects all descendants of a grouplike into a single flat array.
+  // Can be passed a filter function, which will decide whether or not to add
+  // an item to the return array. However, note that all descendants will be
+  // checked against this function; a group will be descended through even if
+  // the filter function checks false against it.
+  // Returns an array, not a grouplike.
+
+  const items = []
+
+  for (const item of grouplike.items) {
+    if (filter === null || filter(item) === true) {
+      items.push(item)
+    }
+
+    if (isGroup(item)) {
+      items.push(...collectGrouplikeChildren(item, filter))
+    }
+  }
+
+  return items
+}
+
+function partiallyFlattenGrouplike(grouplike, resultDepth) {
+  // Flattens a grouplike so that it is never more than a given number of
+  // groups deep, INCLUDING the "top" group -- e.g. a resultDepth of 2
+  // means that there can be one level of groups remaining in the resulting
+  // grouplike, plus the top group.
+
+  if (resultDepth <= 1) {
+    return flattenGrouplike(grouplike)
+  }
+
+  const items = grouplike.items.map(item => {
+    if (isGroup(item)) {
+      return {items: partiallyFlattenGrouplike(item, resultDepth - 1).items}
+    } else {
+      return item
+    }
+  })
+
+  return {items}
+}
+
+function collapseGrouplike(grouplike) {
+  // Similar to partiallyFlattenGrouplike, but doesn't discard the individual
+  // ordering of tracks; rather, it just collapses them all to one level.
+
+  // Gather the groups. The result is an array of groups.
+  // Collapsing [Kar/Baz/Foo, Kar/Baz/Lar] results in [Foo, Lar].
+  // Aha! Just collect the top levels.
+  // Only trouble is what to do with groups that contain both groups and
+  // tracks. Maybe give them their own separate group (e.g. Baz).
+
+  const subgroups = grouplike.items.filter(x => isGroup(x))
+  const nonGroups = grouplike.items.filter(x => !isGroup(x))
+
+  // Get each group's own collapsed groups, and store them all in one big
+  // array.
+  const ret = subgroups.map(group => {
+    return collapseGrouplike(group).items
+  }).reduce((a, b) => a.concat(b), [])
+
+  if (nonGroups.length) {
+    ret.unshift({name: grouplike.name, items: nonGroups})
+  }
+
+  return {items: ret}
+}
+
+function filterGrouplikeByProperty(grouplike, property, value) {
+  // Returns a copy of the original grouplike, only keeping tracks with the
+  // given property-value pair. (If the track's value for the given property
+  // is an array, this will check if that array includes the given value.)
+
+  return Object.assign({}, grouplike, {
+    items: grouplike.items.map(item => {
+      if (isGroup(item)) {
+        const newGroup = filterGrouplikeByProperty(item, property, value)
+        if (newGroup.items.length) {
+          return newGroup
+        } else {
+          return false
+        }
+      } else if (isTrack(item)) {
+        const itemValue = item[property]
+        if (Array.isArray(itemValue) && itemValue.includes(value)) {
+          return item
+        } else if (item[property] === value) {
+          return item
+        } else {
+          return false
+        }
+      } else {
+        return item
+      }
+    }).filter(item => item !== false)
+  })
+}
+
+function filterPlaylistByPathString(playlist, pathString) {
+  // Calls filterGroupContentsByPath, taking an unparsed path string.
+
+  return filterGrouplikeByPath(playlist, parsePathString(pathString))
+}
+
+function filterGrouplikeByPath(grouplike, pathParts) {
+  // Finds a group by following the given group path and returns it. If the
+  // function encounters an item in the group path that is not found, it logs
+  // a warning message and returns the group found up to that point. If the
+  // pathParts array is empty, it returns the group given to the function.
+
+  if (pathParts.length === 0) {
+    return grouplike
+  }
+
+  let firstPart = pathParts[0]
+  let possibleMatches
+
+  if (firstPart.startsWith('?')) {
+    possibleMatches = collectGrouplikeChildren(grouplike)
+    firstPart = firstPart.slice(1)
+  } else {
+    possibleMatches = grouplike.items
+  }
+
+  const titleMatch = (group, caseInsensitive = false) => {
+    let a = group.name
+    let b = firstPart
+
+    if (caseInsensitive) {
+      a = a.toLowerCase()
+      b = b.toLowerCase()
+    }
+
+    return a === b || a === b + '/'
+  }
+
+  let match = possibleMatches.find(g => titleMatch(g, false))
+
+  if (!match) {
+    match = possibleMatches.find(g => titleMatch(g, true))
+  }
+
+  if (match) {
+    if (pathParts.length > 1) {
+      const rest = pathParts.slice(1)
+      return filterGrouplikeByPath(match, rest)
+    } else {
+      return match
+    }
+  } else {
+    console.warn(`Not found: "${firstPart}"`)
+    return null
+  }
+}
+
+function removeGroupByPathString(playlist, pathString) {
+  // Calls removeGroupByPath, taking a path string, rather than a parsed path.
+
+  return removeGroupByPath(playlist, parsePathString(pathString))
+}
+
+function removeGroupByPath(playlist, pathParts) {
+  // Removes the group at the given path from the given playlist.
+
+  const groupToRemove = filterGrouplikeByPath(playlist, pathParts)
+
+  if (groupToRemove === null) {
+    return
+  }
+
+  if (playlist === groupToRemove) {
+    console.error(
+      'You can\'t remove the playlist from itself! Instead, try --clear' +
+      ' (shorthand -c).'
+    )
+
+    return
+  }
+
+  if (!(parentSymbol in groupToRemove)) {
+    console.error(
+      `Group ${pathParts.join('/')} doesn't have a parent, so we can't` +
+      ' remove it from the playlist.'
+    )
+
+    return
+  }
+
+  const parent = groupToRemove[parentSymbol]
+
+  const index = parent.items.indexOf(groupToRemove)
+
+  if (index >= 0) {
+    parent.items.splice(index, 1)
+  } else {
+    console.error(
+      `Group ${pathParts.join('/')} doesn't exist, so we can't explicitly ` +
+      'ignore it.'
+    )
+  }
+}
+
+function getPlaylistTreeString(playlist, showTracks = false) {
+  function recursive(group) {
+    const groups = group.items.filter(x => isGroup(x))
+    const nonGroups = group.items.filter(x => !isGroup(x))
+
+    const childrenString = groups.map(group => {
+      const name = group.name
+      const groupString = recursive(group)
+
+      if (groupString) {
+        const indented = groupString.split('\n').map(l => '| ' + l).join('\n')
+        return '\n' + name + '\n' + indented
+      } else {
+        return name
+      }
+    }).join('\n')
+
+    let tracksString = ''
+    if (showTracks) {
+      tracksString = nonGroups.map(g => g.name).join('\n')
+    }
+
+    if (tracksString && childrenString) {
+      return tracksString + '\n' + childrenString
+    } else if (childrenString) {
+      return childrenString
+    } else if (tracksString) {
+      return tracksString
+    } else {
+      return ''
+    }
+  }
+
+  return recursive(playlist)
+}
+
+function getItemPath(item) {
+  if (item[parentSymbol]) {
+    return [...getItemPath(item[parentSymbol]), item]
+  } else {
+    return [item]
+  }
+}
+
+function getItemPathString(item) {
+  // Gets the playlist path of an item by following its parent chain.
+  //
+  // Returns a string in format Foo/Bar/Baz, where Foo and Bar are group
+  // names, and Baz is the name of the item.
+  //
+  // Unnamed parents are given the name '(Unnamed)'.
+  // Always ignores the root (top) group.
+  //
+  // Requires that the given item be from a playlist processed by
+  // updateGroupFormat.
+
+  // Check if the parent is not the top level group.
+  // The top-level group is included in the return path as '/'.
+  if (item[parentSymbol]) {
+    const displayName = item.name || '(Unnamed)'
+
+    if (item[parentSymbol][parentSymbol]) {
+      return getItemPathString(item[parentSymbol]) + '/' + displayName
+    } else {
+      return '/' + displayName
+    }
+  } else {
+    return '/'
+  }
+}
+
+function parsePathString(pathString) {
+  const pathParts = pathString.split('/').filter(item => item.length)
+  return pathParts
+}
+
+function getTrackIndexInParent(track) {
+  if (parentSymbol in track === false) {
+    throw new Error(
+      'getTrackIndexInParent called with a track that has no parent!'
+    )
+  }
+
+  const parent = track[parentSymbol]
+
+  let i = 0, foundTrack = false;
+  for (; i < parent.items.length; i++) {
+    if (isSameTrack(track, parent.items[i])) {
+      foundTrack = true
+      break
+    }
+  }
+
+  if (foundTrack === false) {
+    return [-1, parent.items.length]
+  } else {
+    return [i, parent.items.length]
+  }
+}
+
+function isGroup(obj) {
+  return !!(obj && obj.items)
+}
+
+function isTrack(obj) {
+  return !!(obj && obj.downloaderArg)
+}
+
+module.exports = {
+  parentSymbol,
+  updatePlaylistFormat, updateTrackFormat,
+  filterTracks,
+  flattenGrouplike,
+  partiallyFlattenGrouplike, collapseGrouplike,
+  filterGrouplikeByProperty,
+  filterPlaylistByPathString, filterGrouplikeByPath,
+  removeGroupByPathString, removeGroupByPath,
+  getPlaylistTreeString,
+  getItemPath, getItemPathString,
+  parsePathString,
+  getTrackIndexInParent,
+  isGroup, isTrack
+}
diff --git a/ui.js b/ui.js
index 26aa12e..8d43db8 100644
--- a/ui.js
+++ b/ui.js
@@ -1,15 +1,18 @@
+const { getDownloaderFor } = require('./downloaders')
+const { getPlayer } = require('./players')
 const ansi = require('./tui-lib/util/ansi')
 const Button = require('./tui-lib/ui/form/Button')
 const FocusElement = require('./tui-lib/ui/form/FocusElement')
 const ListScrollForm = require('./tui-lib/ui/form/ListScrollForm')
 const Pane = require('./tui-lib/ui/Pane')
 const RecordStore = require('./record-store')
+const telc = require('./tui-lib/util/telchars')
 
 class AppElement extends FocusElement {
-  constructor(internalApp) {
+  constructor() {
     super()
 
-    this.internalApp = internalApp
+    this.player = null
     this.recordStore = new RecordStore()
 
     this.pane = new Pane()
@@ -17,6 +20,18 @@ class AppElement extends FocusElement {
 
     this.grouplikeListingElement = new GrouplikeListingElement(this.recordStore)
     this.pane.addChild(this.grouplikeListingElement)
+
+    this.grouplikeListingElement.on('download', item => this.downloadGrouplikeItem(item))
+    this.grouplikeListingElement.on('play', item => this.playGrouplikeItem(item))
+  }
+
+  async setup() {
+    this.player = await getPlayer()
+  }
+
+  async shutdown() {
+    await this.player.kill()
+    this.emit('quitRequested')
   }
 
   fixLayout() {
@@ -31,13 +46,41 @@ class AppElement extends FocusElement {
   }
 
   keyPressed(keyBuf) {
-    if (keyBuf[0] === 0x03) { // ^C
-      this.emit('quitRequested')
+    if (keyBuf[0] === 0x03 || keyBuf[0] === 'q'.charCodeAt(0) || keyBuf[0] === 'Q'.charCodeAt(0)) {
+      this.shutdown()
       return
     }
 
     super.keyPressed(keyBuf)
   }
+
+  async downloadGrouplikeItem(item) {
+    // TODO: Check if it's an item or a group
+    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')
+    }
+
+    // TODO: Check if it's an item or a group
+
+    const downloadFile = await this.downloadGrouplikeItem(item)
+    await this.player.kill()
+    this.recordStore.getRecord(item).playing = true
+    try {
+      await this.player.playFile(downloadFile)
+    } finally {
+      this.recordStore.getRecord(item).playing = false
+    }
+  }
 }
 
 class GrouplikeListingElement extends ListScrollForm {
@@ -59,7 +102,10 @@ class GrouplikeListingElement extends ListScrollForm {
     }
 
     for (const item of this.grouplike.items) {
-      this.addInput(new GrouplikeItemElement(item, this.recordStore))
+      const itemElement = new GrouplikeItemElement(item, this.recordStore)
+      itemElement.on('download', () => this.emit('download', item))
+      itemElement.on('play', () => this.emit('play', item))
+      this.addInput(itemElement)
     }
 
     this.fixLayout()
@@ -100,14 +146,29 @@ class GrouplikeItemElement extends Button {
     const braille = '⠈⠐⠠⠄⠂⠁'
     const brailleChar = braille[Math.floor(Date.now() / 250) % 6]
 
+    const record = this.recordStore.getRecord(this.item)
+
     writable.write(' ')
-    if (this.recordStore.getRecord(this.item).downloading) {
+    if (record.downloading) {
       writable.write(braille[Math.floor(Date.now() / 250) % 6])
+    } else if (record.playing) {
+      writable.write('\u25B6')
     } else {
       writable.write(' ')
     }
     writable.write(' ')
   }
+
+  keyPressed(keyBuf) {
+    // TODO: Helper function for this
+    if (keyBuf[0] === 'd'.charCodeAt(0) || keyBuf[0] === 'D'.charCodeAt(0)) {
+      this.emit('download')
+    }
+
+    if (telc.isSelect(keyBuf)) {
+      this.emit('play')
+    }
+  }
 }
 
 module.exports.AppElement = AppElement