A music player that connects to your cloud/distributed storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: track caching

+197 -28
+8
src/_data/facets.json
··· 22 22 "desc": "Audio playback controller with an artwork display. Play audio from the queue, add tracks to your favourites, control the queue and volume." 23 23 }, 24 24 { 25 + "url": "facets/data/cache-tracks/index.html", 26 + "title": "Cache tracks", 27 + "kind": "prelude", 28 + "category": "Data", 29 + "featured": true, 30 + "desc": "Store tracks locally for offline usage automatically after they've been playing for a while." 31 + }, 32 + { 25 33 "url": "facets/data/export-import/index.html", 26 34 "title": "Export & Import", 27 35 "category": "Data",
+14 -18
src/common/facets/constants.js
··· 20 20 uri: "diffuse://" + facet.url, 21 21 }; 22 22 23 - switch (facet.url) { 24 - case "facets/data/input-bundle/index.html": 25 - return [{ 26 - ...properties, 27 - id: "defaults/input-bundle", 28 - }]; 29 - case "facets/data/output-bundle/index.html": 30 - return [{ 31 - ...properties, 32 - id: "defaults/output-bundle", 33 - }]; 34 - case "facets/data/process-tracks/index.html": 35 - return [{ 36 - ...properties, 37 - id: "defaults/process-tracks", 38 - }]; 39 - default: 40 - return []; 23 + if ( 24 + [ 25 + "facets/data/input-bundle/index.html", 26 + "facets/data/output-bundle/index.html", 27 + "facets/data/process-tracks/index.html", 28 + ].includes(facet.url) 29 + ) { 30 + return [{ 31 + ...properties, 32 + id: "defaults/" + 33 + facet.url.replace(/^\facets\/\w+\//, "").replace(/\/index.html/, ""), 34 + }]; 41 35 } 36 + 37 + return []; 42 38 });
+8 -3
src/components/configurator/input/element.js
··· 2 2 3 3 /** 4 4 * @import {ProxiedActions, Tunnel} from "~/common/worker.d.ts" 5 - * @import {InputActions, InputElement} from "~/components/input/types.d.ts" 5 + * @import {InputElement} from "~/components/input/types.d.ts" 6 + * @import {Actions} from "./types.d.ts" 6 7 */ 7 8 8 9 /** ··· 14 15 //////////////////////////////////////////// 15 16 16 17 /** 17 - * @implements {ProxiedActions<InputActions>} 18 + * @implements {ProxiedActions<Actions>} 18 19 */ 19 20 class InputConfigurator extends DiffuseElement { 20 21 static NAME = "diffuse/configurator/input"; ··· 23 24 constructor() { 24 25 super(); 25 26 26 - /** @type {ProxiedActions<InputActions>} */ 27 + /** @type {ProxiedActions<Actions>} */ 27 28 const proxy = this.workerProxy(); 28 29 29 30 this.consult = proxy.consult; ··· 31 32 this.groupConsult = proxy.groupConsult; 32 33 this.list = proxy.list; 33 34 this.resolve = proxy.resolve; 35 + 36 + this.cache = proxy.cache; 37 + this.listCached = proxy.listCached; 38 + this.removeFromCache = proxy.removeFromCache; 34 39 } 35 40 36 41 // WORKERS
+7
src/components/configurator/input/types.d.ts
··· 1 + import type {InputActions} from "~/components/input/types.d.ts" 2 + 3 + export type Actions = InputActions & { 4 + cache(uris: string[]): Promise<void> 5 + listCached(): Promise<string[]> 6 + removeFromCache(uris: string[]): Promise<void> 7 + };
+84 -7
src/components/configurator/input/worker.js
··· 1 + import * as IDB from "idb-keyval"; 1 2 import * as URI from "fast-uri"; 2 3 3 4 import { groupTracksPerScheme, groupUrisPerScheme } from "~/common/utils.js"; ··· 7 8 * @import {Track} from "~/definitions/types.d.ts"; 8 9 * @import {GroupConsult, InputActions} from "~/components/input/types.d.ts" 9 10 * @import {ActionsWithTunnel, ProxiedActions} from "~/common/worker.d.ts" 11 + * @import {Actions} from "./types.d.ts" 10 12 */ 11 13 12 14 //////////////////////////////////////////// 15 + // LOCAL CACHE 16 + //////////////////////////////////////////// 17 + 18 + const CACHE_KEY_PREFIX = "diffuse/components/configurator/input/cache/"; 19 + 20 + /** @type {Map<string, string>} */ 21 + const blobUrls = new Map(); 22 + 23 + //////////////////////////////////////////// 13 24 // INPUT ACTIONS 14 25 //////////////////////////////////////////// 15 26 16 27 /** 17 - * @type {ActionsWithTunnel<InputActions>['consult']} 28 + * @type {ActionsWithTunnel<Actions>['consult']} 18 29 */ 19 30 export async function consult({ data, ports }) { 20 31 const fileUriOrScheme = data; ··· 32 43 } 33 44 34 45 /** 35 - * @type {ActionsWithTunnel<InputActions>['detach']} 46 + * @type {ActionsWithTunnel<Actions>['detach']} 36 47 */ 37 48 export async function detach({ data, ports }) { 38 49 const cachedTracks = data.tracks; ··· 62 73 } 63 74 64 75 /** 65 - * @type {ActionsWithTunnel<InputActions>['groupConsult']} 76 + * @type {ActionsWithTunnel<Actions>['groupConsult']} 66 77 */ 67 78 export async function groupConsult({ data, ports }) { 68 79 const uris = data; ··· 94 105 } 95 106 96 107 /** 97 - * @type {ActionsWithTunnel<InputActions>['list']} 108 + * @type {ActionsWithTunnel<Actions>['list']} 98 109 */ 99 110 export async function list({ data, ports }) { 100 111 const tracks = data; ··· 126 137 } 127 138 128 139 /** 129 - * @type {ActionsWithTunnel<InputActions>['resolve']} 140 + * @type {ActionsWithTunnel<Actions>['resolve']} 130 141 */ 131 142 export async function resolve({ data, ports }) { 132 143 const uri = data.uri; 144 + 145 + const cachedBlob = 146 + /** @type {Blob | undefined} */ (await IDB.get(CACHE_KEY_PREFIX + uri)); 147 + if (cachedBlob) { 148 + let blobUrl = blobUrls.get(uri); 149 + 150 + if (!blobUrl) { 151 + blobUrl = URL.createObjectURL(cachedBlob); 152 + blobUrls.set(uri, blobUrl); 153 + } 154 + 155 + return { expiresAt: Infinity, url: blobUrl }; 156 + } 157 + 133 158 const scheme = uri.split(":", 1)[0]; 134 159 const input = grabInput(scheme, ports); 135 160 if (!input) return undefined; 136 161 137 - const result = await input.resolve(data); 138 - return result; 162 + return await input.resolve(data); 163 + } 164 + 165 + //////////////////////////////////////////// 166 + // ADDITIONAL ACTIONS 167 + //////////////////////////////////////////// 168 + 169 + /** 170 + * @type {ActionsWithTunnel<Actions>['cache']} 171 + */ 172 + export async function cache({ data, ports }) { 173 + const uris = data; 174 + 175 + await Promise.all(uris.map(async (uri) => { 176 + if (await IDB.get(CACHE_KEY_PREFIX + uri) !== undefined) return; 177 + 178 + const resolved = await resolve({ data: { uri }, ports }); 179 + if (!resolved || "stream" in resolved) return; 180 + 181 + const response = await fetch(resolved.url); 182 + if (!response.ok) return; 183 + 184 + await IDB.set(CACHE_KEY_PREFIX + uri, await response.blob()); 185 + })); 186 + } 187 + 188 + /** 189 + * @type {ActionsWithTunnel<Actions>['listCached']} 190 + */ 191 + export async function listCached() { 192 + const keys = /** @type {string[]} */ (await IDB.keys()); 193 + return keys 194 + .filter((k) => k.startsWith(CACHE_KEY_PREFIX)) 195 + .map((k) => k.slice(CACHE_KEY_PREFIX.length)); 196 + } 197 + 198 + /** 199 + * @type {ActionsWithTunnel<Actions>['removeFromCache']} 200 + */ 201 + export async function removeFromCache({ data }) { 202 + const uris = data; 203 + 204 + await Promise.all(uris.map(async (uri) => { 205 + const blobUrl = blobUrls.get(uri); 206 + if (blobUrl) { 207 + URL.revokeObjectURL(blobUrl); 208 + blobUrls.delete(uri); 209 + } 210 + await IDB.del(CACHE_KEY_PREFIX + uri); 211 + })); 139 212 } 140 213 141 214 //////////////////////////////////////////// ··· 149 222 groupConsult, 150 223 list, 151 224 resolve, 225 + 226 + cache, 227 + listCached, 228 + removeFromCache, 152 229 }); 153 230 }); 154 231
+1
src/facets/data/cache-tracks/index.html
··· 1 + <script type="module" src="facets/data/cache-tracks/index.inline.js"></script>
+75
src/facets/data/cache-tracks/index.inline.js
··· 1 + import { BroadcastableDiffuseElement } from "~/common/element.js"; 2 + import { effect, signal } from "~/common/signal.js"; 3 + import foundation from "~/common/foundation.js"; 4 + 5 + /** 6 + * @import {ScrobbleElement} from "~/components/supplement/types.d.ts" 7 + * @import {Track} from "~/definitions/types.d.ts" 8 + */ 9 + 10 + let initialised = false; 11 + 12 + effect(() => { 13 + const audio = foundation.signals.engine.audio(); 14 + if (!audio) return; 15 + 16 + if (!initialised) { 17 + setupScrobbler(); 18 + initialised = true; 19 + } 20 + }); 21 + 22 + /** 23 + * Pretend we're a scrobbler and cache tracks whenever they are "scrobbled" 24 + */ 25 + async function setupScrobbler() { 26 + await foundation.orchestrator.scrobbleAudio(); 27 + await customElements.whenDefined("dct-scrobbler"); 28 + 29 + const s = new PretendScrobbler(); 30 + s.setAttribute("group", foundation.GROUP); 31 + 32 + const c = await foundation.configurator.scrobbles(); 33 + c.append(s); 34 + } 35 + 36 + /** 37 + * @implements {ScrobbleElement} 38 + */ 39 + class PretendScrobbler extends BroadcastableDiffuseElement { 40 + isAuthenticated = signal(true).get; 41 + isAuthenticating = signal(false).get; 42 + handle = signal(null).get; 43 + 44 + async nowPlaying() {} 45 + 46 + /** 47 + * @param {Track} track 48 + */ 49 + async scrobble(track) { 50 + const i = await foundation.configurator.input(); 51 + await i.cache([track.uri]); 52 + } 53 + 54 + // LIFECYCLE 55 + 56 + /** @override */ 57 + connectedCallback() { 58 + // Broadcast if needed 59 + if (this.hasAttribute("group")) { 60 + const actions = this.broadcast(this.identifier, { 61 + nowPlaying: { strategy: "leaderOnly", fn: this.nowPlaying }, 62 + scrobble: { strategy: "leaderOnly", fn: this.scrobble }, 63 + }); 64 + 65 + if (actions) { 66 + this.nowPlaying = actions.nowPlaying; 67 + this.scrobble = actions.scrobble; 68 + } 69 + } 70 + 71 + super.connectedCallback(); 72 + } 73 + } 74 + 75 + customElements.define("dct-scrobbler", PretendScrobbler);