From 08a3150fe9c9427bdb9e46d0cde14ad30747008d Mon Sep 17 00:00:00 2001 From: Florrie Date: Fri, 5 Jul 2019 21:10:32 -0300 Subject: Separate backend from UI --- backend.js | 520 +++++++++++++++++++++++++++++++++++++++++++++ index.js | 47 +++-- ui.js | 697 ++++++++++++------------------------------------------------- 3 files changed, 692 insertions(+), 572 deletions(-) create mode 100644 backend.js 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 -- cgit 1.3.0-6-gf8a5