« get me outta code hell

mtui - Music Text User Interface - user-friendly command line music player
about summary refs log tree commit diff
path: root/serialized-backend.js
diff options
context:
space:
mode:
Diffstat (limited to 'serialized-backend.js')
-rw-r--r--serialized-backend.js230
1 files changed, 230 insertions, 0 deletions
diff --git a/serialized-backend.js b/serialized-backend.js
new file mode 100644
index 0000000..4b3f845
--- /dev/null
+++ b/serialized-backend.js
@@ -0,0 +1,230 @@
+// Tools for serializing a backend into a JSON-stringifiable object format,
+// and for deserializing this format and loading its contained data into an
+// existing backend instance.
+//
+// Serialized data includes the list of queue players and each player's state
+// (queued items, playback position, etc).
+//
+// Serialized backend data can be used for a variety of purposes, such as
+// writing the data to a file and saving it for later use, or transferring
+// it over an internet connection to synchronize playback with a friend.
+// (The code in socket.js exists to automate this process, as well as to
+// provide a link so that changes to the queue or playback are synchronized
+// in real-time.)
+//
+// TODO: Changes might be necessary all throughout the program to support
+// having any number of objects refer to "the same track", as will likely be
+// the case when restoring from a serialized backend. One way to handle this
+// would be to (perhaps through the existing record store code) keep a handle
+// on each of "the same track", which would be accessed by something like a
+// serialized ID (ala symbols), or maybe just the track name / source URL.
+
+'use strict'
+
+import {
+  isGroup,
+  isTrack,
+  findItemObject,
+  flattenGrouplike,
+  getFlatGroupList,
+  getFlatTrackList,
+  getItemPath
+} from './playlist-utils.js'
+
+const referenceDataSymbol = Symbol('Restored reference data')
+
+function getPlayerInfo(queuePlayer) {
+  const { player } = queuePlayer
+  return {
+    time: queuePlayer.time,
+    isLooping: player.isLooping,
+    isPaused: player.isPaused,
+    volume: player.volume
+  }
+}
+
+export function saveBackend(backend) {
+  return {
+    queuePlayers: backend.queuePlayers.map(QP => ({
+      id: QP.id,
+      playingTrack: saveItemReference(QP.playingTrack),
+      queuedTracks: QP.queueGrouplike.items.map(saveItemReference),
+      pauseNextTrack: QP.pauseNextTrack,
+      playerInfo: getPlayerInfo(QP)
+    }))
+  }
+}
+
+export async function restoreBackend(backend, data) {
+  // console.log('restoring backend:', data)
+
+  if (data.queuePlayers) {
+    if (data.queuePlayers.length === 0) {
+      return
+    }
+
+    for (const qpData of data.queuePlayers) {
+      const QP = await backend.addQueuePlayer()
+      QP[referenceDataSymbol] = qpData
+
+      QP.id = qpData.id
+
+      QP.queueGrouplike.items = qpData.queuedTracks.map(refData => restoreNewItem(refData))
+
+      QP.player.setVolume(qpData.playerInfo.volume)
+      QP.player.setLoop(qpData.playerInfo.isLooping)
+
+      QP.on('playing', () => {
+        QP[referenceDataSymbol].playingTrack = null
+        QP[referenceDataSymbol].playerInfo = null
+      })
+    }
+
+    // We remove the old queue players after the new ones have been added,
+    // because the backend won't let us ever have less than one queue player
+    // at a time.
+    while (backend.queuePlayers.length !== data.queuePlayers.length) {
+      backend.removeQueuePlayer(backend.queuePlayers[0])
+    }
+  }
+}
+
+async function restorePlayingTrack(queuePlayer, playedTrack, playerInfo) {
+  const QP = queuePlayer
+  await QP.stopPlaying()
+  QP.play(playedTrack, playerInfo.time || 0, playerInfo.isPaused)
+}
+
+export function updateRestoredTracksUsingPlaylists(backend, playlists) {
+  // Utility function to restore the "identities" of tracks (i.e. which objects
+  // they are represented by) queued or playing in the provided backend,
+  // pulling possible track identities from the provided playlists.
+  //
+  // How well provided tracks resemble the ones existing in the backend (which
+  // have not already been replaced by an existing track) is calculated with
+  // the algorithm implemented in findItemObject, combining all provided
+  // playlists (simply putting them all in a group) to allow the algorithm to
+  // choose from all playlists equally at once.
+  //
+  // This function should be called after restoring a playlist and whenever
+  // a new source playlist is added (a new tab opened, etc).
+  //
+  // TODO: Though this helps to combat issues with restoring track identities
+  // when restoring from a saved backend, it could be expanded to restore from
+  // closed sources as well (reference data would have to be automatically
+  // saved on the tracks independently of save/restore in order to support
+  // this sort of functionality). Note this would still face difficulties with
+  // opening two identical playlists (i.e. the same playlist twice), since then
+  // identities would be equally correctly picked from either source; this is
+  // an inevitable issue with the way identities are resolved, but could be
+  // lessened in the UI by simply opening a new view (rather than a whole new
+  // load, with new track identities) when a playlist is opened twice at once.
+
+  const possibleChoices = getFlatTrackList({items: playlists})
+
+  for (const QP of backend.queuePlayers) {
+    let playingDataToRestore
+
+    const qpData = (QP[referenceDataSymbol] || {})
+    const waitingTrackData = qpData.playingTrack
+    if (waitingTrackData) {
+      playingDataToRestore = waitingTrackData
+    } else if (QP.playingTrack) {
+      playingDataToRestore = QP.playingTrack[referenceDataSymbol]
+    }
+
+    if (playingDataToRestore) {
+      const found = findItemObject(playingDataToRestore, possibleChoices)
+      if (found) {
+        restorePlayingTrack(QP, found, qpData.playerInfo || getPlayerInfo(QP))
+      }
+    }
+
+    QP.queueGrouplike.items = QP.queueGrouplike.items.map(track => {
+      const refData = track[referenceDataSymbol]
+      if (!refData) {
+        return track
+      }
+
+      return findItemObject(refData, possibleChoices) || track
+    })
+
+    QP.emit('queue updated')
+  }
+}
+
+export function saveItemReference(item) {
+  // Utility function to generate reference data for a track or grouplike,
+  // according to the format taken by findItemObject.
+
+  if (isTrack(item)) {
+    return {
+      name: item.name,
+      path: getItemPath(item).slice(0, -1).map(group => group.name),
+      downloaderArg: item.downloaderArg
+    }
+  } else if (isGroup(item)) {
+    return {
+      name: item.name,
+      path: getItemPath(item).slice(0, -1).map(group => group.name),
+      items: item.items.map(saveItemReference)
+    }
+  } else if (item) {
+    return item
+  } else {
+    return null
+  }
+}
+
+export function restoreNewItem(referenceData, playlists) {
+  // Utility function to restore a new item. If you're restoring tracks
+  // already present in a backend, use the specific function for that,
+  // updateRestoredTracksUsingPlaylists.
+  //
+  // This function takes a playlists array like the function for restoring
+  // tracks in a backend, but in this function, it's optional: if not provided,
+  // it will simply skip searching for a resembling track and return a new
+  // track object right away.
+
+  let found
+  if (playlists) {
+    let possibleChoices
+    if (referenceData.downloaderArg) {
+      possibleChoices = getFlatTrackList({items: playlists})
+    } else if (referenceData.items) {
+      possibleChoices = getFlatGroupList({items: playlists})
+    }
+    if (possibleChoices) {
+      found = findItemObject(referenceData, possibleChoices)
+    }
+  }
+
+  if (found) {
+    return found
+  } else if (referenceData.downloaderArg) {
+    return {
+      [referenceDataSymbol]: referenceData,
+      name: referenceData.name,
+      downloaderArg: referenceData.downloaderArg
+    }
+  } else if (referenceData.items) {
+    return {
+      [referenceDataSymbol]: referenceData,
+      name: referenceData.name,
+      items: referenceData.items.map(item => restoreNewItem(item, playlists))
+    }
+  } else {
+    return {
+      [referenceDataSymbol]: referenceData,
+      name: referenceData.name
+    }
+  }
+}
+
+export function getWaitingTrackData(queuePlayer) {
+  // Utility function to get reference data for the track which is currently
+  // waiting to be played, once a resembling track is found. This should only
+  // be used to reflect that data in the user interface.
+
+  return (queuePlayer[referenceDataSymbol] || {}).playingTrack
+}