123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815 |
- // 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'
- import {readFile, writeFile} from 'node:fs/promises'
- import EventEmitter from 'node:events'
- import os from 'node:os'
- import {getDownloaderFor} from './downloaders.js'
- import {getMetadataReaderFor} from './metadata-readers.js'
- import {getPlayer} from './players.js'
- import RecordStore from './record-store.js'
- import {
- getTimeStringsFromSec,
- shuffleArray,
- throttlePromise,
- } from './general-util.js'
- import {
- isGroup,
- isTrack,
- flattenGrouplike,
- parentSymbol,
- } from './playlist-utils.js'
- async function download(item, record) {
- if (isGroup(item)) {
- // TODO: Download all children (recursively), show a confirmation prompt
- // if there are a lot of items (remember to flatten).
- return
- }
- // You can't download things that aren't tracks!
- if (!isTrack(item)) {
- return
- }
- // Don't start downloading an item if we're already downloading it!
- if (record.downloading) {
- return
- }
- const arg = item.downloaderArg
- record.downloading = true
- try {
- return await getDownloaderFor(arg)(arg)
- } finally {
- record.downloading = false
- }
- }
- class QueuePlayer extends EventEmitter {
- constructor({
- getPlayer,
- getRecordFor
- }) {
- super()
- this.player = null
- this.playingTrack = null
- this.queueGrouplike = {name: 'Queue', isTheQueue: true, items: []}
- this.pauseNextTrack = false
- this.queueEndMode = 'end' // end, loop, shuffle
- this.playedTrackToEnd = false
- this.timeData = null
- this.getPlayer = getPlayer
- this.getRecordFor = getRecordFor
- }
- async setup() {
- this.player = await this.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) {
- const oldTimeData = this.timeData
- this.timeData = data
- this.emit('received time data', data, oldTimeData, this)
- }
- })
- return true
- }
- 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
- // Keeps track of how many tracks have been removed (times -1); this is
- // used so we queue tracks at the intended spot.
- let removeOffset = 0
- const recursivelyAddTracks = item => {
- // For groups, just queue all children.
- if (isGroup(item)) {
- for (const child of item.items) {
- recursivelyAddTracks(child)
- }
- return
- }
- // If the item isn't a track, it can't be queued.
- if (!isTrack(item)) {
- 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
- }
- const removeIndex = items.indexOf(item)
- items.splice(removeIndex, 1)
- // If the item we removed was positioned before the insertion index,
- // we need to shift that index back one, so it's placed after the same
- // intended track.
- if (removeIndex <= afterIndex) {
- removeOffset--
- }
- }
- if (afterItem === 'FRONT') {
- items.unshift(item)
- } else if (afterItem) {
- items.splice(afterIndex + 1 + grouplikeOffset + removeOffset, 0, item)
- } else {
- items.push(item)
- }
- grouplikeOffset++
- }
- recursivelyAddTracks(topItem)
- this.emitQueueUpdated()
- // 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 newTracks = flattenGrouplike(grouplike).items.filter(isTrack)
- // 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 newTracks) {
- if (items.includes(item)) {
- /*
- if (!movePlayingTrack && item === this.playingTrack) {
- // NB: if uncommenting this code, splice item from newTracks 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
- if (how === 'evenly') {
- let offset = 0
- for (const item of newTracks) {
- const insertIndex = distributeStart + Math.floor(offset)
- items.splice(insertIndex, 0, item)
- offset++
- offset += distributeSize / newTracks.length
- }
- } else if (how === 'randomly') {
- const indexes = newTracks.map(() => Math.floor(Math.random() * distributeSize))
- indexes.sort()
- for (let i = 0; i < newTracks.length; i++) {
- const item = newTracks[i]
- const insertIndex = distributeStart + indexes[i] + i
- items.splice(insertIndex, 0, item)
- }
- }
- this.emitQueueUpdated()
- }
- 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)
- this.emitQueueUpdated()
- return focusItem
- }
- clearQueuePast(track) {
- const { items } = this.queueGrouplike
- const index = items.indexOf(track) + 1
- if (index < 0) {
- return
- } else if (index < items.indexOf(this.playingTrack)) {
- items.splice(index, items.length - index, this.playingTrack)
- } else {
- items.splice(index)
- }
- this.emitQueueUpdated()
- }
- clearQueueUpTo(track) {
- const { items } = this.queueGrouplike
- const endIndex = items.indexOf(track)
- const startIndex = (this.playingTrack ? items.indexOf(this.playingTrack) + 1 : 0)
- if (endIndex < 0) {
- return
- } else if (endIndex < startIndex) {
- return
- } else {
- items.splice(startIndex, endIndex - startIndex)
- }
- this.emitQueueUpdated()
- }
- 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.playNext(track)
- }
- }
- shuffleQueue(pastPlayingTrackOnly = true) {
- const queue = this.queueGrouplike
- const index = (pastPlayingTrackOnly
- ? queue.items.indexOf(this.playingTrack) + 1 // This is 0 if no track is playing
- : 0)
- const initialItems = queue.items.slice(0, index)
- const remainingItems = queue.items.slice(index)
- const newItems = initialItems.concat(shuffleArray(remainingItems))
- queue.items = newItems
- this.emitQueueUpdated()
- }
- 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.emitQueueUpdated()
- }
- emitQueueUpdated() {
- this.emit('queue updated')
- }
- async stopPlaying() {
- // We emit this so the active play() call doesn't immediately start a new
- // track. We aren't *actually* about to play a new track.
- this.emit('playing new track')
- await this.player.kill()
- this.clearPlayingTrack()
- }
- async play(item, startTime = 0) {
- 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
- }
- // If it's not a track, you can't play it.
- if (!isTrack(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.timeData = null
- this.playingTrack = item
- this.emit('playing', this.playingTrack, oldTrack, startTime, this)
- await this.player.kill()
- if (this.playedTrackToEnd) {
- this.player.setPause(this.pauseNextTrack)
- this.pauseNextTrack = false
- this.playedTrackToEnd = false
- } else {
- this.player.setPause(false)
- }
- await this.player.playFile(downloadFile, startTime)
- }
- // playingThisTrack now means whether the track played through to the end
- // (true), or was stopped by a different track being started (false).
- if (playingThisTrack) {
- this.playedTrackToEnd = true
- this.playNext(item)
- }
- }
- playNext(track, automaticallyQueueNextTrack = false) {
- if (!track) return false
- // Auto-queue is nice but it should only happen when the queue hasn't been
- // explicitly set to loop.
- automaticallyQueueNextTrack = (
- automaticallyQueueNextTrack &&
- this.queueEndMode === 'end')
- 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
- let index = parent.items.indexOf(track)
- let nextItem
- do {
- nextItem = parent.items[++index]
- } while (nextItem && !(isTrack(nextItem) || isGroup(nextItem)))
- if (!nextItem) return false
- this.queue(nextItem)
- queueIndex = queue.items.length - 1
- } else {
- return this.playNextAtQueueEnd()
- }
- }
- 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
- let index = parent.items.indexOf(track)
- let previousItem
- do {
- previousItem = parent.items[--index]
- } while (previousItem && !(isTrack(previousItem) || isGroup(previousItem)))
- if (!previousItem) return false
- this.queue(previousItem, 'FRONT')
- queueIndex = 0
- } else {
- return false
- }
- }
- this.play(queue.items[queueIndex])
- return true
- }
- playFirst() {
- const queue = this.queueGrouplike
- if (queue.items.length) {
- this.play(queue.items[0])
- return true
- }
- return false
- }
- playNextAtQueueEnd() {
- switch (this.queueEndMode) {
- case 'loop':
- this.playFirst()
- return true
- case 'shuffle':
- this.shuffleQueue(false)
- this.playFirst()
- return true
- case 'end':
- default:
- this.clearPlayingTrack()
- return false
- }
- }
- async playOrSeek(item, time) {
- if (!isTrack(item)) {
- // This only makes sense to call with individual tracks!
- return
- }
- if (item === this.playingTrack) {
- this.seekTo(time)
- } else {
- // Queue the track, but only if it's not already in the queue, so that we
- // respect an existing queue order.
- const queue = this.queueGrouplike
- const queueIndex = queue.items.indexOf(item)
- if (queueIndex === -1) {
- this.queue(item, this.playingTrack)
- }
- this.play(item, time)
- }
- }
- clearPlayingTrack() {
- if (this.playingTrack !== null) {
- const oldTrack = this.playingTrack
- this.playingTrack = null
- this.timeData = null
- this.emit('playing', null, oldTrack, 0, this)
- }
- }
- async download(item) {
- return download(item, this.getRecordFor(item))
- }
- seekAhead(seconds) {
- this.player.seekAhead(seconds)
- }
- seekBack(seconds) {
- this.player.seekBack(seconds)
- }
- seekTo(seconds) {
- this.player.seekTo(seconds)
- }
- seekToStart() {
- this.player.seekToStart()
- }
- 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)
- }
- setVolumeMultiplier(value) {
- this.player.setVolumeMultiplier(value);
- }
- fadeIn() {
- return this.player.fadeIn();
- }
- setPauseNextTrack(value) {
- this.pauseNextTrack = !!value
- }
- setLoopQueueAtEnd(value) {
- this.loopQueueAtEnd = !!value
- this.emit('set-loop-queue-at-end', !!value)
- }
- get remainingTracks() {
- const index = this.queueGrouplike.items.indexOf(this.playingTrack)
- const length = this.queueGrouplike.items.length
- if (index === -1) {
- return length
- } else {
- return length - index - 1
- }
- }
- get playSymbol() {
- if (this.player && this.playingTrack) {
- if (this.player.isPaused) {
- return '⏸'
- } else {
- return '▶'
- }
- } else {
- return '.'
- }
- }
- }
- export default class Backend extends EventEmitter {
- constructor({
- playerName = null,
- playerOptions = []
- } = {}) {
- super()
- this.playerName = playerName;
- this.playerOptions = playerOptions;
- if (playerOptions.length && !playerName) {
- throw new Error(`Must specify playerName to specify playerOptions`);
- }
- this.queuePlayers = []
- this.recordStore = new RecordStore()
- this.throttleMetadata = throttlePromise(10)
- this.metadataDictionary = {}
- this.rootDirectory = os.homedir() + '/.mtui'
- this.metadataPath = this.rootDirectory + '/track-metadata.json'
- }
- async setup() {
- const error = await this.addQueuePlayer()
- if (error.error) {
- return error
- }
- await this.loadMetadata()
- return true
- }
- async addQueuePlayer() {
- const queuePlayer = new QueuePlayer({
- getPlayer: () => getPlayer(this.playerName, this.playerOptions),
- getRecordFor: item => this.getRecordFor(item)
- })
- const error = await queuePlayer.setup()
- if (error.error) {
- return error
- }
- this.queuePlayers.push(queuePlayer)
- this.emit('added queue player', queuePlayer)
- for (const event of [
- 'playing',
- 'done playing',
- 'queue',
- 'distribute-queue',
- 'unqueue',
- 'clear-queue-past',
- 'clear-queue-up-to',
- 'shuffle-queue',
- 'clear-queue',
- 'queue updated',
- 'seek-ahead',
- 'seek-back',
- 'toggle-pause',
- 'set-pause',
- 'toggle-loop',
- 'set-loop',
- 'vol-up',
- 'vol-down',
- 'set-volume',
- 'set-pause-next-track',
- 'set-loop-queue-at-end'
- ]) {
- queuePlayer.on(event, (...data) => {
- this.emit(event, queuePlayer, ...data)
- })
- }
- return queuePlayer
- }
- removeQueuePlayer(queuePlayer) {
- if (this.queuePlayers.length > 1) {
- this.queuePlayers.splice(this.queuePlayers.indexOf(queuePlayer), 1)
- this.emit('removed queue player', queuePlayer)
- }
- }
- 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 if (isTrack(item)) 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)
- }
- getDuration(item) {
- let noticedMissingMetadata = false
- const durationFn = (acc, track) => {
- const metadata = this.getMetadataFor(track)
- if (!metadata) noticedMissingMetadata = true
- return acc + (metadata && metadata.duration) || 0
- }
- let items
- if (isGroup(item)) {
- items = flattenGrouplike(item).items
- } else {
- items = [item]
- }
- const tracks = items.filter(isTrack)
- const seconds = tracks.reduce(durationFn, 0)
- let { duration: string } = getTimeStringsFromSec(0, seconds)
- const approxSymbol = noticedMissingMetadata ? '+' : ''
- string += approxSymbol
- return {seconds, string, noticedMissingMetadata, approxSymbol}
- }
- async stopPlayingAll() {
- for (const queuePlayer of this.queuePlayers) {
- await queuePlayer.stopPlaying()
- }
- }
- async download(item) {
- return download(item, this.getRecordFor(item))
- }
- }
|