diff --git a/src/_data/facets.json b/src/_data/facets.json index c527175b..6673b2ff 100644 --- a/src/_data/facets.json +++ b/src/_data/facets.json @@ -21,6 +21,14 @@ "featured": true, "desc": "Audio playback controller with an artwork display. Play audio from the queue, add tracks to your favourites, control the queue and volume." }, + { + "url": "facets/data/cache-tracks/index.html", + "title": "Cache tracks", + "kind": "prelude", + "category": "Data", + "featured": true, + "desc": "Store tracks locally for offline usage automatically after they've been playing for a while." + }, { "url": "facets/data/export-import/index.html", "title": "Export & Import", diff --git a/src/common/facets/constants.js b/src/common/facets/constants.js index 947583f0..70c47b1a 100644 --- a/src/common/facets/constants.js +++ b/src/common/facets/constants.js @@ -20,23 +20,19 @@ export const STARTING_SET = facets.flatMap((facet) => { uri: "diffuse://" + facet.url, }; - switch (facet.url) { - case "facets/data/input-bundle/index.html": - return [{ - ...properties, - id: "defaults/input-bundle", - }]; - case "facets/data/output-bundle/index.html": - return [{ - ...properties, - id: "defaults/output-bundle", - }]; - case "facets/data/process-tracks/index.html": - return [{ - ...properties, - id: "defaults/process-tracks", - }]; - default: - return []; + if ( + [ + "facets/data/input-bundle/index.html", + "facets/data/output-bundle/index.html", + "facets/data/process-tracks/index.html", + ].includes(facet.url) + ) { + return [{ + ...properties, + id: "defaults/" + + facet.url.replace(/^\facets\/\w+\//, "").replace(/\/index.html/, ""), + }]; } + + return []; }); diff --git a/src/components/configurator/input/element.js b/src/components/configurator/input/element.js index 49fb69fb..45b11080 100644 --- a/src/components/configurator/input/element.js +++ b/src/components/configurator/input/element.js @@ -2,7 +2,8 @@ import { DiffuseElement } from "~/common/element.js"; /** * @import {ProxiedActions, Tunnel} from "~/common/worker.d.ts" - * @import {InputActions, InputElement} from "~/components/input/types.d.ts" + * @import {InputElement} from "~/components/input/types.d.ts" + * @import {Actions} from "./types.d.ts" */ /** @@ -14,7 +15,7 @@ import { DiffuseElement } from "~/common/element.js"; //////////////////////////////////////////// /** - * @implements {ProxiedActions} + * @implements {ProxiedActions} */ class InputConfigurator extends DiffuseElement { static NAME = "diffuse/configurator/input"; @@ -23,7 +24,7 @@ class InputConfigurator extends DiffuseElement { constructor() { super(); - /** @type {ProxiedActions} */ + /** @type {ProxiedActions} */ const proxy = this.workerProxy(); this.consult = proxy.consult; @@ -31,6 +32,10 @@ class InputConfigurator extends DiffuseElement { this.groupConsult = proxy.groupConsult; this.list = proxy.list; this.resolve = proxy.resolve; + + this.cache = proxy.cache; + this.listCached = proxy.listCached; + this.removeFromCache = proxy.removeFromCache; } // WORKERS diff --git a/src/components/configurator/input/types.d.ts b/src/components/configurator/input/types.d.ts new file mode 100644 index 00000000..12064c57 --- /dev/null +++ b/src/components/configurator/input/types.d.ts @@ -0,0 +1,7 @@ +import type {InputActions} from "~/components/input/types.d.ts" + +export type Actions = InputActions & { + cache(uris: string[]): Promise + listCached(): Promise + removeFromCache(uris: string[]): Promise +}; diff --git a/src/components/configurator/input/worker.js b/src/components/configurator/input/worker.js index 0eab37c6..8cee4d3e 100644 --- a/src/components/configurator/input/worker.js +++ b/src/components/configurator/input/worker.js @@ -1,3 +1,4 @@ +import * as IDB from "idb-keyval"; import * as URI from "fast-uri"; import { groupTracksPerScheme, groupUrisPerScheme } from "~/common/utils.js"; @@ -7,14 +8,24 @@ import { ostiary, rpc, workerProxy } from "~/common/worker.js"; * @import {Track} from "~/definitions/types.d.ts"; * @import {GroupConsult, InputActions} from "~/components/input/types.d.ts" * @import {ActionsWithTunnel, ProxiedActions} from "~/common/worker.d.ts" + * @import {Actions} from "./types.d.ts" */ +//////////////////////////////////////////// +// LOCAL CACHE +//////////////////////////////////////////// + +const CACHE_KEY_PREFIX = "diffuse/components/configurator/input/cache/"; + +/** @type {Map} */ +const blobUrls = new Map(); + //////////////////////////////////////////// // INPUT ACTIONS //////////////////////////////////////////// /** - * @type {ActionsWithTunnel['consult']} + * @type {ActionsWithTunnel['consult']} */ export async function consult({ data, ports }) { const fileUriOrScheme = data; @@ -32,7 +43,7 @@ export async function consult({ data, ports }) { } /** - * @type {ActionsWithTunnel['detach']} + * @type {ActionsWithTunnel['detach']} */ export async function detach({ data, ports }) { const cachedTracks = data.tracks; @@ -62,7 +73,7 @@ export async function detach({ data, ports }) { } /** - * @type {ActionsWithTunnel['groupConsult']} + * @type {ActionsWithTunnel['groupConsult']} */ export async function groupConsult({ data, ports }) { const uris = data; @@ -94,7 +105,7 @@ export async function groupConsult({ data, ports }) { } /** - * @type {ActionsWithTunnel['list']} + * @type {ActionsWithTunnel['list']} */ export async function list({ data, ports }) { const tracks = data; @@ -126,16 +137,78 @@ export async function list({ data, ports }) { } /** - * @type {ActionsWithTunnel['resolve']} + * @type {ActionsWithTunnel['resolve']} */ export async function resolve({ data, ports }) { const uri = data.uri; + + const cachedBlob = + /** @type {Blob | undefined} */ (await IDB.get(CACHE_KEY_PREFIX + uri)); + if (cachedBlob) { + let blobUrl = blobUrls.get(uri); + + if (!blobUrl) { + blobUrl = URL.createObjectURL(cachedBlob); + blobUrls.set(uri, blobUrl); + } + + return { expiresAt: Infinity, url: blobUrl }; + } + const scheme = uri.split(":", 1)[0]; const input = grabInput(scheme, ports); if (!input) return undefined; - const result = await input.resolve(data); - return result; + return await input.resolve(data); +} + +//////////////////////////////////////////// +// ADDITIONAL ACTIONS +//////////////////////////////////////////// + +/** + * @type {ActionsWithTunnel['cache']} + */ +export async function cache({ data, ports }) { + const uris = data; + + await Promise.all(uris.map(async (uri) => { + if (await IDB.get(CACHE_KEY_PREFIX + uri) !== undefined) return; + + const resolved = await resolve({ data: { uri }, ports }); + if (!resolved || "stream" in resolved) return; + + const response = await fetch(resolved.url); + if (!response.ok) return; + + await IDB.set(CACHE_KEY_PREFIX + uri, await response.blob()); + })); +} + +/** + * @type {ActionsWithTunnel['listCached']} + */ +export async function listCached() { + const keys = /** @type {string[]} */ (await IDB.keys()); + return keys + .filter((k) => k.startsWith(CACHE_KEY_PREFIX)) + .map((k) => k.slice(CACHE_KEY_PREFIX.length)); +} + +/** + * @type {ActionsWithTunnel['removeFromCache']} + */ +export async function removeFromCache({ data }) { + const uris = data; + + await Promise.all(uris.map(async (uri) => { + const blobUrl = blobUrls.get(uri); + if (blobUrl) { + URL.revokeObjectURL(blobUrl); + blobUrls.delete(uri); + } + await IDB.del(CACHE_KEY_PREFIX + uri); + })); } //////////////////////////////////////////// @@ -149,6 +222,10 @@ ostiary((context) => { groupConsult, list, resolve, + + cache, + listCached, + removeFromCache, }); }); diff --git a/src/facets/data/cache-tracks/index.html b/src/facets/data/cache-tracks/index.html new file mode 100644 index 00000000..5f7bbd0e --- /dev/null +++ b/src/facets/data/cache-tracks/index.html @@ -0,0 +1 @@ + diff --git a/src/facets/data/cache-tracks/index.inline.js b/src/facets/data/cache-tracks/index.inline.js new file mode 100644 index 00000000..3cafb110 --- /dev/null +++ b/src/facets/data/cache-tracks/index.inline.js @@ -0,0 +1,75 @@ +import { BroadcastableDiffuseElement } from "~/common/element.js"; +import { effect, signal } from "~/common/signal.js"; +import foundation from "~/common/foundation.js"; + +/** + * @import {ScrobbleElement} from "~/components/supplement/types.d.ts" + * @import {Track} from "~/definitions/types.d.ts" + */ + +let initialised = false; + +effect(() => { + const audio = foundation.signals.engine.audio(); + if (!audio) return; + + if (!initialised) { + setupScrobbler(); + initialised = true; + } +}); + +/** + * Pretend we're a scrobbler and cache tracks whenever they are "scrobbled" + */ +async function setupScrobbler() { + await foundation.orchestrator.scrobbleAudio(); + await customElements.whenDefined("dct-scrobbler"); + + const s = new PretendScrobbler(); + s.setAttribute("group", foundation.GROUP); + + const c = await foundation.configurator.scrobbles(); + c.append(s); +} + +/** + * @implements {ScrobbleElement} + */ +class PretendScrobbler extends BroadcastableDiffuseElement { + isAuthenticated = signal(true).get; + isAuthenticating = signal(false).get; + handle = signal(null).get; + + async nowPlaying() {} + + /** + * @param {Track} track + */ + async scrobble(track) { + const i = await foundation.configurator.input(); + await i.cache([track.uri]); + } + + // LIFECYCLE + + /** @override */ + connectedCallback() { + // Broadcast if needed + if (this.hasAttribute("group")) { + const actions = this.broadcast(this.identifier, { + nowPlaying: { strategy: "leaderOnly", fn: this.nowPlaying }, + scrobble: { strategy: "leaderOnly", fn: this.scrobble }, + }); + + if (actions) { + this.nowPlaying = actions.nowPlaying; + this.scrobble = actions.scrobble; + } + } + + super.connectedCallback(); + } +} + +customElements.define("dct-scrobbler", PretendScrobbler);