A music player that connects to your cloud/distributed storage. diffuse.sh
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722import * as TID from "@atcute/tid";import * as IDB from "idb-keyval";import { html, nothing, render as litRender } from "lit-html";
import * as Output from "~/common/output.js";import { CACHE_KEY_PREFIX, SCHEME as SCHEME_EPHEMERAL_CACHE,} from "~/components/input/ephemeral-cache/constants.js";import { isAudioFile } from "~/components/input/common.js";import { SCHEME as SCHEME_DROPBOX } from "~/components/input/dropbox/constants.js";import { buildURI, parseURI as parseS3URI,} from "~/components/input/s3/common.js";import { SCHEME as SCHEME_S3 } from "~/components/input/s3/constants.js";import { buildURI as buildWebdavURI, parseURI as parseWebdavURI,} from "~/components/input/webdav/common.js";import { SCHEME as SCHEME_WEBDAV } from "~/components/input/webdav/constants.js";import { effect, signal } from "~/common/signal.js";import { safeDecodeURIComponent } from "~/common/utils.js";import foundation from "~/common/foundation.js";
/** * @import { TemplateResult } from "lit-html" * @import {Track} from "~/definitions/types.d.ts" * @import {UploadElement} from "@specs/components/upload/types.d.ts" * @import {Bucket} from "@specs/components/input/s3/types.d.ts" * @import {Server} from "@specs/components/input/webdav/types.d.ts" */
/** Human-readable labels for upload component schemes. *//** @type {Record<string, string>} */const SCHEME_LABELS = { [SCHEME_DROPBOX]: "Dropbox", [SCHEME_S3]: "S3", [SCHEME_WEBDAV]: "WebDAV",};
/** Directory in the cloud where uploaded files are stored. */const UPLOAD_DIRECTORY = "/Diffuse";
/** IDB key for persisting the sync scheme across sessions. */const SYNC_SCHEME_KEY = "file-manager:sync-scheme";
/** IDB key for storing a pending delete path (retried after reconnection). */const PENDING_DELETE_KEY = "file-manager:pending-delete";
/** IDB key for a persisted map of CID → original filename, so the * "Artist - Title" label survives upload (remote tracks) and reloads. */const CID_NAMES_KEY = "file-manager:cid-names";
foundation.setup({ title: "File Manager" });
////////////////////////////////////////////// SETUP////////////////////////////////////////////
const [ inputConfigurator, outputOrchestrator, uploadConfigurator, sourcesOrchestrator, processOrchestrator,] = await Promise.all([ foundation.configurator.input(), foundation.orchestrator.output(), foundation.configurator.upload(), foundation.orchestrator.sources(), foundation.orchestrator.processTracks({ disableWhenReady: true }),]);
await Promise.all([ customElements.whenDefined(inputConfigurator.localName), customElements.whenDefined(outputOrchestrator.localName), customElements.whenDefined(uploadConfigurator.localName), customElements.whenDefined(sourcesOrchestrator.localName), customElements.whenDefined(processOrchestrator.localName),]);
/** Maps ephemeral cache URIs to their original filename. */const ephemeralNames = new Map();
/** Maps a content CID to its original filename. Persisted to IDB so the label * is available for remote tracks (after upload) and across reloads. */const cidNames = new Map();
/** Bumped whenever `ephemeralNames` or `cidNames` is updated so the reactive * effect re-fires (they're plain Maps, not signals). */const namesVersion = signal(0);
/** Tracks the upload button state: "idle", "caching", or "uploading". */const uploadState = signal( /** @type {"idle" | "caching" | "uploading"} */ ("idle"),);
/** The scheme currently being synced with (e.g. "dropbox"), or null. */const syncScheme = signal(/** @type {string | null} */ (null));
////////////////////////////////////////////// UI////////////////////////////////////////////
/** * @typedef {{ name: string; subtitle?: string; onRemove: () => void }} FileItem */
/** * Renders the File Manager layout (left: logo, title, description; right: the * supplied content, an error callout, and reactive lists of items) and returns * helpers to update it. * * @param {Object} config * @param {string} config.title * @param {TemplateResult | string} config.description * @param {TemplateResult} [config.leftContent] * @param {TemplateResult} config.rightContent * @returns {{ setLocalItems: (items: FileItem[]) => void, setRemoteItems: (items: FileItem[]) => void, setError: (message: string | TemplateResult | null) => void }} */function setup({ title, description, leftContent, rightContent }) { const main = document.querySelector("main"); if (!main) throw new Error("No <main> element");
litRender( html` <div class="facet__left"> <div> <a href="./dashboard/" class="diffuse-logo-container"> <svg viewBox="0 0 902 134" width="160"> <title>Diffuse</title> <use xlink:href="images/diffuse-current.svg#diffuse" href="images/diffuse-current.svg#diffuse" ></use> </svg> </a> </div> <h1>${title}</h1> ${description} ${leftContent} </div> <div class="facet__right"> ${rightContent} <div id="file-card-error" class="callout callout--danger" hidden></div> <div id="local-section" class="file-section" hidden> <h2 class="file-section__title">Local tracks</h2> <ul id="local-list" class="file-list"></ul> </div> <div id="remote-section" class="file-section" hidden> <h2 class="file-section__title">Remote tracks</h2> <ul id="remote-list" class="file-list"></ul> </div> </div> `, main, );
const cardErrorEl = /** @type {HTMLElement} */ (main.querySelector("#file-card-error")); const localSection = /** @type {HTMLElement} */ (main.querySelector("#local-section")); const remoteSection = /** @type {HTMLElement} */ (main.querySelector("#remote-section")); const localList = /** @type {HTMLElement} */ (main.querySelector("#local-list")); const remoteList = /** @type {HTMLElement} */ (main.querySelector("#remote-list"));
/** * @param {string | TemplateResult | null} message */ const setError = (message) => { cardErrorEl.hidden = message === null; litRender(message ?? nothing, cardErrorEl); };
/** @param {FileItem[]} items */ const setLocalItems = (items) => { localSection.hidden = items.length === 0; renderFileList(localList, items, "local"); };
/** @param {FileItem[]} items */ const setRemoteItems = (items) => { remoteSection.hidden = items.length === 0; renderFileList(remoteList, items, "remote"); };
return { setLocalItems, setRemoteItems, setError };}
/** * Renders a list of file items into the given list element. * * @param {HTMLElement} listEl * @param {FileItem[]} items * @param {string} prefix - Unique prefix for popover IDs to avoid collisions. */function renderFileList(listEl, items, prefix) { litRender( html` ${items.map( ({ name, subtitle, onRemove }, index) => html` <li class="file-item"> <div class="file-item__info"> <span class="file-item__name">${name}</span> ${subtitle ? html`<span class="file-item__subtitle monospace-font">${subtitle}</span>` : null} </div> <button class="button--plain button--icon" aria-label="More" popovertarget="${prefix}-menu-${index}" > <i class="ph-fill ph-dots-three-outline-vertical"></i> </button> <div id="${prefix}-menu-${index}" class="dropdown" popover> ${subtitle ? html` <button @click="${(/** @type {MouseEvent} */ e) => { /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e .currentTarget).closest("[popover]"))?.hidePopover(); navigator.clipboard?.writeText(subtitle).catch( () => {}, ); }}" > <i class="ph-fill ph-copy"></i> Copy CID </button> ` : null} <button @click="${(/** @type {MouseEvent} */ e) => { /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e .currentTarget).closest("[popover]"))?.hidePopover(); onRemove(); }}" > <i class="ph-fill ph-skull"></i> Delete </button> </div> </li> `, )} `, listEl, );}
const { setLocalItems, setRemoteItems, setError } = setup({ title: "File Manager", description: html` <p>Upload audio files to play them and optionally sync them with your cloud storage. These are automatically added as inputs (aka. sources) and then processed into playable tracks.</p>
<p> <small style="display: block;"> Got music stored elsewhere, or don't want Diffuse to manage your music? There's a bunch more options to <a href="l/?path=facets%2Fconnect%2Findex.tile">connect</a> to with just read-only access. </small> </p> `,
leftContent: html` <div class="button-row" style="align-items: center; margin-top: var(--space-md)"> <button id="upload-btn" popovertarget="upload-menu"> <i id="upload-icon" class="ph-fill ph-cloud-arrow-up"></i> <span id="upload-label">Upload tracks</span> </button> <div id="upload-menu" class="dropdown" popover></div> <button id="stop-sync-btn" class="button--danger" hidden> <i class="ph-fill ph-cloud-slash"></i> <span id="stop-sync-label">Stop syncing</span> </button> <button id="process-btn"> <i id="process-icon" class="ph-fill ph-arrows-clockwise"></i> <span id="process-label">Process</span> </button> <i id="upload-indicator" class="ph-bold ph-spinner animate-spin upload-indicator" hidden></i> </div>
<dialog id="connect-source-dialog"> <div class="dialog-header"> <strong id="connect-source-title"></strong> </div> <form id="connect-source-form" class="dialog-body"> <div id="connect-source-error" class="callout callout--danger" hidden style="margin: 0"></div> <div id="connect-source-existing" hidden></div> <div id="connect-source-fields"></div> <p id="connect-source-caption" class="caption" style="margin: 0"></p> </form> <div class="dialog-footer"> <button id="connect-source-submit-btn" type="submit" form="connect-source-form" class="button--brand">Connect</button> <button id="connect-source-cancel-btn" type="button">Cancel</button> </div> </dialog> `,
rightContent: html` <label class="dropzone" id="local-dropzone"> <input id="local-dropzone-input" type="file" accept="audio/*" multiple hidden /> <i class="ph-bold ph-upload-simple"></i> <span>Drop or click to select files</span> </label> `,});
const dropzone = document.querySelector("#local-dropzone");const dropzoneInput = /** @type {HTMLInputElement | null} */ (document.querySelector( "#local-dropzone-input", ));
const uploadBtn = /** @type {HTMLButtonElement | null} */ ( document.querySelector("#upload-btn"));const uploadIcon = /** @type {HTMLElement | null} */ (document.querySelector( "#upload-icon",));const uploadLabel = /** @type {HTMLElement | null} */ (document.querySelector( "#upload-label",));const uploadMenu = /** @type {HTMLElement | null} */ (document.querySelector( "#upload-menu",));
const stopSyncBtn = /** @type {HTMLButtonElement | null} */ ( document.querySelector("#stop-sync-btn"));const stopSyncLabel = /** @type {HTMLElement | null} */ ( document.querySelector("#stop-sync-label"));const uploadIndicator = /** @type {HTMLElement | null} */ ( document.querySelector("#upload-indicator"));
const processBtn = /** @type {HTMLButtonElement | null} */ ( document.querySelector("#process-btn"));const processIcon = /** @type {HTMLElement | null} */ ( document.querySelector("#process-icon"));const processLabel = /** @type {HTMLElement | null} */ ( document.querySelector("#process-label"));
dropzoneInput?.addEventListener("change", async () => { const files = Array.from(dropzoneInput.files ?? []).filter((f) => f.type.startsWith("audio/") ); dropzoneInput.value = ""; if (files.length === 0) return; await cacheFiles(files);});
dropzone?.addEventListener("dragover", (e) => { e.preventDefault(); dropzone.classList.add("dropzone--active");});
dropzone?.addEventListener("dragleave", () => { dropzone.classList.remove("dropzone--active");});
dropzone?.addEventListener("drop", async (e) => { e.preventDefault(); dropzone.classList.remove("dropzone--active");
const dragEvent = /** @type {DragEvent} */ (e); // `dataTransfer.files` is reliably populated in every browser, but Firefox // (and older Safari) can return `null` from `getAsFile()` for files dragged // in from the OS. Read the direct files from `files` and only use `items` to // additionally expand dropped directories via the (non-standard) entries API. const directFiles = Array.from(dragEvent.dataTransfer?.files ?? []); const items = Array.from(dragEvent.dataTransfer?.items ?? []); const files = await collectFiles(items, directFiles); if (files.length === 0) return;
await cacheFiles(files);});
stopSyncBtn?.addEventListener("click", () => stopSyncing());
////////////////////////////////////////////// CONNECT SOURCE DIALOG////////////////////////////////////////////
/** * @typedef {{ * id: string; * label: string; * required?: boolean; * placeholder?: string; * type?: "text" | "password"; * }} ConnectField */
/** * Describes how to connect a source for a scheme without an OAuth flow, * so the file manager can host the connect form itself. * * @typedef {{ * scheme: string; * fields: ConnectField[]; * build: (values: Record<string, string>) => string; * caption: string; * sourceParts: (uri: string) => { name: string; detail: string }; * }} ConnectConfig */
/** @type {Record<string, ConnectConfig>} */const CONNECT_CONFIGS = { [SCHEME_S3]: { scheme: SCHEME_S3,
fields: [ { id: "access-key", label: "Access key", required: true }, { id: "secret-key", label: "Secret key", required: true, type: "password", }, { id: "bucket-name", label: "Bucket name", required: true, placeholder: "my-bucket", }, { id: "host", label: "Host", placeholder: "s3.amazonaws.com" }, { id: "region", label: "Region", placeholder: "us-east-1" }, { id: "path", label: "Path", placeholder: "/" }, ],
build: (values) => { /** @type {Bucket} */ const bucket = { accessKey: values["access-key"], bucketName: values["bucket-name"], host: values.host ? values.host.replace(/^\w+:\/\//, "") : "s3.amazonaws.com", path: values.path || "/", region: values.region || "us-east-1", secretKey: values["secret-key"], }; return buildURI(bucket); },
caption: "Uploaded files are placed inside the bucket's path.",
sourceParts: (uri) => { const parsed = parseS3URI(uri); if (!parsed) return { name: uri, detail: "" }; return { name: parsed.bucket.bucketName || parsed.bucket.host, detail: parsed.bucket.host, }; }, },
[SCHEME_WEBDAV]: { scheme: SCHEME_WEBDAV,
fields: [ { id: "host", label: "Host", required: true, placeholder: "music.example.com", }, { id: "dir", label: "Directory", placeholder: "/" }, { id: "username", label: "Username" }, { id: "password", label: "Password", type: "password" }, ],
build: (values) => { /** @type {Server} */ const server = { dir: values.dir || "/", host: values.host, password: values.password || "", username: values.username || "", }; return buildWebdavURI(server); },
caption: "Uploaded files are placed inside the server's directory.",
sourceParts: (uri) => { const parsed = parseWebdavURI(uri); if (!parsed) return { name: uri, detail: "" }; return { name: parsed.server.host, detail: parsed.server.dir, }; }, },};
const connectSourceDialog = /** @type {HTMLDialogElement | null} */ ( document.querySelector("#connect-source-dialog"));const connectSourceForm = /** @type {HTMLFormElement | null} */ ( document.querySelector("#connect-source-form"));const connectSourceErrorEl = /** @type {HTMLElement | null} */ (document.querySelector( "#connect-source-error", ));const connectSourceFieldsEl = /** @type {HTMLElement | null} */ (document.querySelector( "#connect-source-fields", ));const connectSourceExistingEl = /** @type {HTMLElement | null} */ (document.querySelector( "#connect-source-existing", ));const connectSourceCaptionEl = /** @type {HTMLElement | null} */ (document.querySelector( "#connect-source-caption", ));const connectSourceTitleEl = /** @type {HTMLElement | null} */ (document.querySelector( "#connect-source-title", ));const connectSourceSubmitBtn = /** @type {HTMLButtonElement | null} */ ( document.querySelector("#connect-source-submit-btn"));
/** The config of the scheme the dialog is currently open for. */let currentConnectConfig = /** @type {ConnectConfig | null} */ (null);
/** * Shows the dialog-level error inside the connect source dialog. * * @param {string | null} message */const setConnectSourceError = (message) => { if (!connectSourceErrorEl) return; connectSourceErrorEl.hidden = message === null; connectSourceErrorEl.textContent = message;};
/** * Enables/disables the dialog's interactive elements while an action * (consulting a source, connecting a new one) is in flight. * * @param {boolean} busy */const setConnectSourceBusy = (busy) => { connectSourceSubmitBtn?.toggleAttribute("disabled", busy); connectSourceExistingEl?.querySelectorAll("button").forEach((btn) => { btn.toggleAttribute("disabled", busy); });};
/** * Opens the connect source dialog for the given scheme: existing sources for * the scheme are offered as destinations to sync with (the user chooses — * none is silently preferred), plus a form to connect a new one. * * @param {ConnectConfig} config */function openConnectSourceDialog(config) { if ( !connectSourceDialog || !connectSourceForm || !connectSourceFieldsEl || !connectSourceExistingEl ) return;
currentConnectConfig = config;
const label = SCHEME_LABELS[config.scheme] ?? config.scheme; if (connectSourceTitleEl) { connectSourceTitleEl.textContent = `Set up syncing (${label})`; } if (connectSourceCaptionEl) { connectSourceCaptionEl.textContent = config.caption; }
// Destinations already connected for this scheme. const sources = sourcesOrchestrator.sources()[config.scheme] ?? []; const existing = sources.map((source) => ({ ...source, ...config.sourceParts(source.uri), disabled: sourcesOrchestrator.isDisabled(source.uri), }));
connectSourceExistingEl.hidden = existing.length === 0; litRender( existing.length === 0 ? nothing : html` <p style="margin-bottom: var(--space-2xs); margin-top: 0;"> Sync with a connected source: </p> <ul class="file-list"> ${existing.map(({ uri, name, detail, disabled }) => html` <li class="file-item"> <button type="button" class="button--plain connect-source-option" title="${disabled ? "Enable and sync with" : "Sync with"} ${name}" @click="${() => selectExistingSource(config, uri, disabled)}" > <i class="ph-fill ph-cloud-arrow-up"></i> <div class="file-item__info"> <span class="file-item__name">${name}${disabled ? " (disabled)" : ""}</span> ${detail ? html`<span class="file-item__subtitle">${detail}</span>` : null} </div> </button> </li> ` )} </ul> <p style="margin-top: var(--space-md);"> Or connect a new one: </p> `, connectSourceExistingEl, );
litRender( config.fields.map((field) => html` <label> <span class="label__name">${field.label}</span> <input id="connect-source-field-${field.id}" type="${field.type ?? "text"}" placeholder="${field.placeholder ?? ""}" ?required="${field.required ?? false}" autocomplete="off" > </label> ` ), connectSourceFieldsEl, );
connectSourceForm.reset(); setConnectSourceError(null); connectSourceDialog.showModal();}
/** * Selects an existing source as the syncing destination. Consults it first — * an unreachable source can't be synced with, so the dialog stays open with * an error instead. * * @param {ConnectConfig} config * @param {string} uri * @param {boolean} disabled - Whether the source is currently disabled * (it is re-enabled when syncing starts). */async function selectExistingSource(config, uri, disabled) { setConnectSourceError(null); setConnectSourceBusy(true);
try { const result = await uploadConfigurator.consult(uri); if (result.supported && result.consult === "no") { setConnectSourceError( "This source could not be reached right now. Check its credentials (and the server's CORS settings), then try again.", ); return; }
if (disabled) await sourcesOrchestrator.toggle(uri);
connectSourceDialog?.close(); await beginSync(config.scheme, uri); } catch (err) { setConnectSourceError( err instanceof Error ? err.message : "Failed to start syncing", ); } finally { setConnectSourceBusy(false); }}
connectSourceForm?.addEventListener("submit", async (e) => { e.preventDefault(); const config = currentConnectConfig; if (!connectSourceDialog || !config) return;
// Collect the field values. const values = /** @type {Record<string, string>} */ ({}); for (const field of config.fields) { values[field.id] = /** @type {HTMLInputElement | null} */ ( document.querySelector(`#connect-source-field-${field.id}`) )?.value?.trim() ?? ""; }
// Required fields are enforced by the browser as well — this covers // programmatic submissions. if (config.fields.some((f) => f.required && !values[f.id])) return;
setConnectSourceError(null); setConnectSourceBusy(true); if (connectSourceSubmitBtn) { connectSourceSubmitBtn.textContent = "Connecting …"; }
try { const uri = config.build(values);
// Verify the new source is reachable before saving it — a typo'd secret // key or missing CORS config shouldn't create a junk source. const result = await uploadConfigurator.consult(uri); if (result.supported && result.consult === "no") { setConnectSourceError( "The source could not be reached. Check the values (and the server's CORS settings), then try again.", ); return; }
// Add the source, the same way the Connect page does. const now = new Date().toISOString(); /** @type {Track} */ const placeholderTrack = { $type: "sh.diffuse.output.track", id: TID.now(), createdAt: now, updatedAt: now, kind: "placeholder", uri, }; const tracks = await Output.data(outputOrchestrator.tracks); await outputOrchestrator.tracks.save([...tracks, placeholderTrack]);
connectSourceDialog.close(); await beginSync(config.scheme, uri); } catch (err) { setConnectSourceError( err instanceof Error ? err.message : "Failed to connect the source", ); } finally { setConnectSourceBusy(false); if (connectSourceSubmitBtn) { connectSourceSubmitBtn.textContent = "Connect"; } }});
document.querySelector("#connect-source-cancel-btn")?.addEventListener( "click", () => { setConnectSourceError(null); connectSourceDialog?.close(); },);
////////////////////////////////////////////// OAUTH CALLBACK////////////////////////////////////////////
// Detect the OAuth callback: if `?uploading=<scheme>` is in the URL and// `#refresh_token=...` is in the hash, we just returned from the OAuth// provider. Clean the URL (remove only the `uploading` param and the hash,// preserving any other query parameters the loader needs) and resume the// upload flow.{ const url = new URL(location.href); const uploadingScheme = url.searchParams.get("uploading");
if (uploadingScheme) { const hashParams = new URLSearchParams(url.hash.slice(1)); const refreshToken = hashParams.get("refresh_token");
// Clean URL: remove the `uploading` param and the hash, keep everything else. url.searchParams.delete("uploading"); url.hash = ""; history.replaceState({}, "", url);
if (refreshToken) { // Don't await — let the page render while the upload runs. resumeUpload(uploadingScheme, refreshToken); } else { setError("Authorization failed. Please try again."); } }}
////////////////////////////////////////////// REACTIVE LISTS////////////////////////////////////////////
// Recover filenames for tracks cached in previous sessions: the cached// `File` blob retains its `.name` across IDB round-trips, so we read it back// once on load and seed the in-memory map before the first render.await (async () => { // Restore the persisted CID → filename map so remote tracks (uploaded in a // previous session) can still show their "Artist - Title" label. const persisted = await IDB.get(CID_NAMES_KEY); if (persisted) { for (const [cid, name] of Object.entries(persisted)) { cidNames.set(cid, name); } }
// Recover filenames for tracks still in the ephemeral cache: the cached // `File` blob retains its `.name` across IDB round-trips, so we read it back // once on load and seed the in-memory maps before the first render. const tracks = await Output.data(outputOrchestrator.tracks); const ephemeralUris = tracks .filter((t) => t.uri.startsWith("ephemeral+cache://")) .map((t) => t.uri);
await Promise.all( ephemeralUris.map(async (uri) => { if (ephemeralNames.has(uri)) return; const blob = await IDB.get(CACHE_KEY_PREFIX + uri); if (blob?.name) { ephemeralNames.set(uri, blob.name); const cid = cidFromUri(uri); if (cid) cidNames.set(cid, blob.name); } }), ); namesVersion.value++;})();
// Load persisted sync scheme.{ const scheme = await IDB.get(SYNC_SCHEME_KEY); if (scheme) syncScheme.value = scheme;}
effect(() => { // Re-fire when ephemeralNames/cidNames are updated (plain Maps, not signals). namesVersion.get(); const tracksCol = outputOrchestrator.tracks.collection(); const tracks = tracksCol.state === "loaded" ? tracksCol.data : [];
// Local tracks: ephemeral cache tracks (files cached locally, not yet // uploaded to the cloud). The label is the original filename without its // extension (e.g. "Artist - Title"); the CID is shown as a subtitle. const localEntries = tracks .filter((t) => t.uri.startsWith("ephemeral+cache://")) .map((t) => { const cid = cidFromUri(t.uri); const original = ephemeralNames.get(t.uri) ?? cidNames.get(cid); return { label: original ? stemOf(original) : cid, cid, uri: t.uri, }; }) .sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }) );
setLocalItems( localEntries.map(({ label, cid, uri }) => ({ name: label, subtitle: cid, onRemove: () => removeLocalEntry(uri, label), })), );
// Remote tracks: tracks from the selected upload method's input only. // When no upload method is selected, no remote tracks are shown. The cloud // file is named `<CID>.<ext>`, so we look up the original filename via the // CID to show "Artist - Title", falling back to the cloud filename. const scheme = syncScheme.get(); const remoteEntries = scheme ? tracks .filter( (t) => t.uri.startsWith(scheme + "://") && t.kind !== "placeholder", ) .map((t) => { const cid = cidFromUri(t.uri); const original = cidNames.get(cid); return { label: original ? stemOf(original) : trackLabel(t.uri), cid, uri: t.uri, }; }) .sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }) ) : [];
setRemoteItems( remoteEntries.map(({ label, cid, uri }) => ({ name: label, subtitle: cid, onRemove: () => removeRemoteEntry(uri, label), })), );});
////////////////////////////////////////////// UPLOAD BUTTON / STOP SYNCING////////////////////////////////////////////
// When not syncing: show the "Connect" button with a dropdown of available// upload methods. When syncing: show a "Stop syncing" button that disconnects// the upload method and stops auto-uploading.effect(() => { if (!uploadBtn || !uploadIcon || !uploadLabel || !uploadMenu) return; if (!stopSyncBtn || !stopSyncLabel) return;
const state = uploadState.get(); const isBusy = state !== "idle"; if (uploadIndicator) { uploadIndicator.hidden = !isBusy; uploadIndicator.title = state === "caching" ? "Processing local files …" : state === "uploading" ? "Uploading to cloud storage …" : ""; }
const syncing = syncScheme.get();
if (syncing) { // Sync mode: hide upload button, show stop syncing button. uploadBtn.hidden = true; uploadMenu.hidePopover?.(); stopSyncBtn.hidden = false; stopSyncLabel.textContent = `Stop syncing (${ SCHEME_LABELS[syncing] ?? syncing })`; return; }
// Idle mode: show upload button, hide stop syncing button. uploadBtn.hidden = false; uploadMenu.hidden = false; stopSyncBtn.hidden = true;
const uploadComponents = uploadConfigurator.uploaders(); const uploadEntries = Object.entries(uploadComponents);
const canUpload = uploadEntries.length > 0;
uploadBtn.disabled = isBusy || !canUpload; uploadIcon.className = "ph-fill ph-cloud-arrow-up"; uploadLabel.textContent = isBusy ? "Uploading ..." : "Set up syncing";
litRender( html` ${uploadEntries.map(([scheme, element]) => html` <button @click="${(/** @type {MouseEvent} */ e) => { /** @type {HTMLElement | null} */ ( /** @type {HTMLElement} */ (e.currentTarget).closest( "[popover]", ) )?.hidePopover(); startUpload(scheme, element); }}" > <i class="ph-fill ph-cloud-arrow-up"></i> ${SCHEME_LABELS[scheme] ?? scheme} </button> ` )} `, uploadMenu, );});
////////////////////////////////////////////// PROCESS BUTTON////////////////////////////////////////////
effect(() => { if (!processBtn || !processIcon || !processLabel) return;
const isProcessing = processOrchestrator.isProcessing(); const { processed, total } = processOrchestrator.progress(); const pct = total > 0 ? Math.round((processed / total) * 100) : null;
processBtn.disabled = isProcessing; processIcon.className = isProcessing ? "ph-fill ph-arrows-clockwise animate-spin" : "ph-fill ph-arrows-clockwise"; processLabel.textContent = isProcessing ? (pct !== null ? `Processing (${pct}%)` : "Listing") : "Process";});
processBtn?.addEventListener("click", async () => { await Output.data(outputOrchestrator.tracks); // (Re)process local files (the ephemeral cache) and — while syncing — the // connected cloud source too, so freshly uploaded tracks get metadata. // The orchestrator skips tracks that already have tags & stats (and // placeholders), so this is incremental. When not syncing, only local // files are processed: the file manager manages no cloud source then. const scheme = syncScheme.value; await processOrchestrator.process([ `${SCHEME_EPHEMERAL_CACHE}://`, ...(scheme ? [`${scheme}://`] : []), ]);});
////////////////////////////////////////////// ACTIONS////////////////////////////////////////////
/** * Extracts a human-readable label from a track URI (the last path segment, * URL-decoded). * * @param {string} uri * @returns {string} */function trackLabel(uri) { const withoutQuery = uri.split("?")[0]; const parts = withoutQuery.split("/"); const last = parts[parts.length - 1]; return last ? safeDecodeURIComponent(last) : uri;}
/** * Extracts the file extension (without the dot) from a filename, or "" if it * has none. Used to build the cloud filename as `<CID>.<ext>`. * * @param {string} name * @returns {string} */function extOf(name) { const slash = Math.max(name.lastIndexOf("/"), name.lastIndexOf("\\")); const base = slash >= 0 ? name.slice(slash + 1) : name; const dot = base.lastIndexOf("."); return dot > 0 ? base.slice(dot + 1) : "";}
/** * Returns the filename without its extension — the "Artist - Title" part of * "Artist - Title.mp3". * * @param {string} name * @returns {string} */function stemOf(name) { const slash = Math.max(name.lastIndexOf("/"), name.lastIndexOf("\\")); const base = slash >= 0 ? name.slice(slash + 1) : name; const dot = base.lastIndexOf("."); return dot > 0 ? base.slice(0, dot) : base;}
/** * Extracts the content CID from a track URI. For ephemeral tracks the URI is * `ephemeral+cache://<cid>`; for remote tracks the last path segment is the * uploaded file `<cid>.<ext>`. * * @param {string} uri * @returns {string} */function cidFromUri(uri) { const schemeSep = uri.indexOf("://"); if (schemeSep < 0) return ""; const rest = uri.slice(schemeSep + 3).split("?")[0]; const slash = rest.lastIndexOf("/"); const file = slash >= 0 ? rest.slice(slash + 1) : rest; const dot = file.lastIndexOf("."); return dot > 0 ? file.slice(0, dot) : file;}
/** * Finds the first enabled (non-disabled) source URI for the given scheme. * * @param {string} scheme * @returns {string | null} */function enabledSourceUri(scheme) { const sourcesRecord = sourcesOrchestrator.sources(); const sources = (sourcesRecord[scheme] ?? []).filter( (s) => !sourcesOrchestrator.isDisabled(s.uri), ); return sources.length > 0 ? sources[0].uri : null;}
/** * @param {string} uri * @param {string} name */async function removeLocalEntry(uri, name) { if (!confirm(`Are you sure you want to remove "${name}"?`)) return; setError(null); try { const tracks = await Output.data(outputOrchestrator.tracks); const detachedTracks = await inputConfigurator.detach({ fileUriOrScheme: uri, tracks, });
if (detachedTracks) { await outputOrchestrator.tracks.save(detachedTracks); ephemeralNames.delete(uri); const cid = cidFromUri(uri); if (cid && cidNames.delete(cid)) { await IDB.set(CID_NAMES_KEY, Object.fromEntries(cidNames)); } namesVersion.value++; } } catch (err) { setError(err instanceof Error ? err.message : "Failed to remove entry"); }}
/** * Deletes a remote track: first deletes the file from the cloud, then * detaches the track from the output. * * If the access token has expired, the delete is stored as pending and the * OAuth reconnection flow is triggered automatically. After reconnection, * `resumeUpload` picks up the pending delete and retries it. * * @param {string} uri * @param {string} name */async function removeRemoteEntry(uri, name) { if (!confirm(`Are you sure you want to remove "${name}"?`)) return; setError(null); try { // Delete the file from the cloud first. await uploadConfigurator.delete(uri);
// Drop the stored label for this CID; the file is gone. const cid = cidFromUri(uri); if (cid && cidNames.delete(cid)) { await IDB.set(CID_NAMES_KEY, Object.fromEntries(cidNames)); }
// Then remove just this track from the output (not the entire source — // `inputConfigurator.detach` would remove all tracks for the account). const tracks = await Output.data(outputOrchestrator.tracks); const filteredTracks = tracks.filter((t) => t.uri !== uri); await outputOrchestrator.tracks.save(filteredTracks);
// Re-list from the cloud and save the result. This ensures the output // is consistent with the cloud state. Without this, the process-tracks // orchestrator's `mergeById` (which uses stale `cachedTracks` from the // start of its run) can bring back the deleted track on the next // `process()` call. const listed = await inputConfigurator.list(filteredTracks); await outputOrchestrator.tracks.save(listed); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to remove entry"; if (msg.includes("expired")) { // Token expired — store the file path so the delete can be retried // after reconnection, then trigger OAuth. try { const path = new URL(uri).pathname; await IDB.set(PENDING_DELETE_KEY, path); } catch { // If path extraction fails, the user can retry manually after // reconnection. } reconnectSync(); return; } setError(msg); }}
/** * Uploads all ephemeral cache tracks to the selected source's cloud storage, * then removes them from the ephemeral cache (they're now in the cloud and * will be picked up by the matching input component's `list()` on the next * processing run). * * @param {string} sourceUri - The account/source URI to upload to. */async function uploadTracks(sourceUri) { setError(null); uploadState.value = "uploading";
try { const tracks = await Output.data(outputOrchestrator.tracks); const ephemeralTracks = tracks.filter((t) => t.uri.startsWith("ephemeral+cache://") );
if (ephemeralTracks.length === 0) return;
// Reconstruct Files from the IDB-cached blobs. The uploaded file is // named after its content ID with the original extension preserved // (e.g. "bafy…foo.mp3"), so duplicate uploads don't clobber each other // and the filename is stable across re-uploads. The cached blob is // itself a `File` and retains its `.name` across IDB round-trips, so it's // a reliable fallback when `ephemeralNames` hasn't been seeded yet (e.g. // right after an OAuth redirect). const files = await Promise.all( ephemeralTracks.map(async (track) => { const blob = await IDB.get(CACHE_KEY_PREFIX + track.uri); const original = ephemeralNames.get(track.uri) ?? blob?.name ?? "audio"; const cid = cidFromUri(track.uri); const ext = extOf(original); const name = ext ? `${cid}.${ext}` : cid; return new File([blob], name, { type: blob?.type ?? "audio/*" }); }), );
// Upload each file to the selected source's account. await Promise.all( files.map((file) => uploadConfigurator.upload({ file, uri: sourceUri })), );
// Remove the ephemeral cache tracks (they're now in the cloud). const detachedTracks = await inputConfigurator.detach({ fileUriOrScheme: SCHEME_EPHEMERAL_CACHE, tracks, }); await outputOrchestrator.tracks.save(detachedTracks);
// Clean up the filename map for the now-removed ephemeral tracks. for (const track of ephemeralTracks) { ephemeralNames.delete(track.uri); } namesVersion.value++;
// List tracks from all input sources so the newly uploaded files appear. // We call `list` directly (instead of relying on the process-tracks // orchestrator) for reliability — the orchestrator may not be fully // initialised yet when this runs. const listed = await inputConfigurator.list(detachedTracks); await outputOrchestrator.tracks.save(listed); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to upload tracks"; if (msg.includes("expired")) { // Token expired — trigger reconnection. Ephemeral tracks are preserved // in IDB and will be uploaded automatically after reconnection. reconnectSync(); return; } setError(msg); } finally { uploadState.value = "idle"; }}
/** * Uploads all ephemeral cache tracks to the sync source, then drains: if new * ephemeral tracks appeared during the upload (e.g. from concurrent drops), * uploads them too. */async function syncUpload() { if (uploadState.get() === "uploading") return; if (!syncScheme.value) return;
const sourceUri = enabledSourceUri(syncScheme.value); if (!sourceUri) return;
await uploadTracks(sourceUri);
// Drain: check if there are new ephemeral tracks (e.g. from concurrent // drops) and upload them too. if (syncScheme.value) { const tracks = await Output.data(outputOrchestrator.tracks); const hasEphemeral = tracks.some((t) => t.uri.startsWith("ephemeral+cache://") ); if (hasEphemeral) { await syncUpload(); } }}
/** * Enters sync mode for the given scheme: arms auto-uploading of new local * tracks and persists the scheme so it survives reloads. * * Only called once the syncing destination is chosen (and consulted) — * entering sync mode earlier would flash the "Stop syncing" button in the * UI. * * @param {string} scheme */async function enterSyncMode(scheme) { syncScheme.value = scheme; await IDB.set(SYNC_SCHEME_KEY, scheme);}
/** * Arms syncing for the given source and uploads all pending local tracks to * it. The destination must already be consulted (or have just been created). * * @param {string} scheme * @param {string} sourceUri */async function beginSync(scheme, sourceUri) { await enterSyncMode(scheme); await uploadTracks(sourceUri);}
/** * Starts the sync flow for the selected upload component. For uploaders * without an OAuth flow this opens the connect dialog, where the user picks * which existing source to sync with or connects a new one — no source is * silently preferred, since the user may have several and only want to sync * to one. OAuth uploaders reuse an existing source if its token is still * valid (re-enabling if necessary), or trigger the OAuth flow (adding * `?uploading=<scheme>` to the URL so we can resume after the redirect). * * @param {string} scheme * @param {UploadElement} uploadElement */async function startUpload(scheme, uploadElement) { setError(null);
// Non-OAuth uploaders with a connect form: always ask for the destination. const connectConfig = CONNECT_CONFIGS[scheme]; if (connectConfig) { openConnectSourceDialog(connectConfig); return; }
const sourcesRecord = sourcesOrchestrator.sources(); const allSources = sourcesRecord[scheme] ?? []; const enabledSources = allSources.filter( (s) => !sourcesOrchestrator.isDisabled(s.uri), ); const disabledSources = allSources.filter((s) => sourcesOrchestrator.isDisabled(s.uri) );
// Check if an existing source's token is still valid before re-using it. // If the token has expired (consult returns "no"), fall through to the // OAuth flow to get a fresh token. if (enabledSources.length > 0) { const result = await uploadConfigurator.consult(enabledSources[0].uri); if (result.supported && result.consult !== "no") { // Token is valid (or inconclusive — be optimistic): upload directly. await beginSync(scheme, enabledSources[0].uri); return; } }
if (disabledSources.length > 0) { const result = await uploadConfigurator.consult(disabledSources[0].uri); if (result.supported && result.consult !== "no") { // Token is valid — re-enable the source, then upload. await sourcesOrchestrator.toggle(disabledSources[0].uri); await beginSync(scheme, disabledSources[0].uri); return; } }
// No usable existing source — authenticate via OAuth. if (!uploadElement.authorize) { // Fallback for uploaders without an OAuth flow or a connect form: they // can't mint a source themselves — one must be connected first, and if // one exists but is unreachable, re-authenticating can't help. const label = SCHEME_LABELS[scheme] ?? scheme; const connectPath = uploadElement.CONNECT_PATH ?? "facets/connect/index.tile"; const connectLink = html`<a href="l/?path=${ encodeURIComponent(connectPath) }">Connect page</a>`;
setError( allSources.length === 0 ? html`No ${label} source is connected. Connect one on the ${connectLink} first, then set up syncing again.` : html`The ${label} storage could not be reached. Check its credentials (and the bucket's CORS settings) on the ${connectLink}, then try again.`, ); return; }
// Enter sync mode before the redirect so it survives the page reload // (resumeUpload sets it again as well). await enterSyncMode(scheme);
const url = new URL(location.href); url.searchParams.set("uploading", scheme); history.replaceState({}, "", url);
uploadElement.authorize(); // Page will redirect to the OAuth provider.}
/** * Resumes the sync flow after returning from the OAuth redirect. The refresh * token is in the URL hash; we use it to build a placeholder track (via the * upload component's `createSource`), upload files to that account, then save * the placeholder track so the matching input component lists the files. * * @param {string} scheme * @param {string} refreshToken */async function resumeUpload(scheme, refreshToken) { setError(null); uploadState.value = "uploading";
// Enter sync mode (persisted before the redirect, but set again for safety). syncScheme.value = scheme; await IDB.set(SYNC_SCHEME_KEY, scheme);
try { /** @type {UploadElement | undefined} */ const uploadElement = uploadConfigurator.uploaders()[scheme]; if (!uploadElement) { setError(`Unsupported upload scheme: ${scheme}`); return; }
const tracks = await Output.data(outputOrchestrator.tracks); const ephemeralTracks = tracks.filter((t) => t.uri.startsWith("ephemeral+cache://") );
// Build the placeholder track — its URI is the account URI we upload to. // Routed through the configurator so it reaches the correct upload // component by scheme. const placeholderTrack = await uploadConfigurator.createSource({ scheme, refreshToken, directoryPath: UPLOAD_DIRECTORY, }); const uri = placeholderTrack.uri;
// Upload ephemeral tracks if any. if (ephemeralTracks.length > 0) { // Reconstruct Files from the IDB-cached blobs. The uploaded file is // named after its content ID with the original extension preserved // (e.g. "bafy…foo.mp3"). The cached blob is itself a `File` and retains // its `.name` across IDB round-trips, so it's a reliable fallback when // `ephemeralNames` hasn't been seeded yet (e.g. right after an OAuth // redirect). const files = await Promise.all( ephemeralTracks.map(async (track) => { const blob = await IDB.get(CACHE_KEY_PREFIX + track.uri); const original = ephemeralNames.get(track.uri) ?? blob?.name ?? "audio"; const cid = cidFromUri(track.uri); const ext = extOf(original); const name = ext ? `${cid}.${ext}` : cid; return new File([blob], name, { type: blob?.type ?? "audio/*" }); }), );
// Upload each file to the account. await Promise.all( files.map((file) => uploadConfigurator.upload({ file, uri })), ); }
// Replace ALL existing tracks for this scheme (old placeholder + remote // tracks with an expired token) with the new placeholder. A new token // creates a different account ID, so old tracks won't be matched by a // fresh listing — they must be explicitly removed. const schemePrefix = scheme + "://"; const tracksWithoutOldScheme = tracks.filter( (t) => !t.uri.startsWith(schemePrefix), ); const tracksWithPlaceholder = [...tracksWithoutOldScheme, placeholderTrack]; await outputOrchestrator.tracks.save(tracksWithPlaceholder);
// Remove the ephemeral cache tracks if any were uploaded. let detachedTracks = tracksWithPlaceholder; if (ephemeralTracks.length > 0) { detachedTracks = await inputConfigurator.detach({ fileUriOrScheme: SCHEME_EPHEMERAL_CACHE, tracks: tracksWithPlaceholder, }); await outputOrchestrator.tracks.save(detachedTracks);
// Clean up the filename map for the now-removed ephemeral tracks. for (const track of ephemeralTracks) { ephemeralNames.delete(track.uri); } namesVersion.value++; }
// List tracks from all input sources so the newly uploaded files appear. // We call `list` directly (instead of relying on the process-tracks // orchestrator) for reliability — the orchestrator may not be fully // initialised yet when this runs. const listed = await inputConfigurator.list(detachedTracks); await outputOrchestrator.tracks.save(listed);
// Retry a pending delete if one was stored (e.g. the user tried to delete // a track but the token had expired — after reconnection the track is // re-listed with a fresh token, so we retry the delete now). const pendingPath = await IDB.get(PENDING_DELETE_KEY); if (pendingPath) { await IDB.del(PENDING_DELETE_KEY); const target = listed.find((t) => { try { return new URL(t.uri).pathname === pendingPath; } catch { return false; } }); if (target) { await uploadConfigurator.delete(target.uri); // Remove just this track from the output (not the entire source — // `inputConfigurator.detach` would remove all tracks for the account). const afterDelete = listed.filter((t) => t.uri !== target.uri); await outputOrchestrator.tracks.save(afterDelete); } } } catch (err) { setError(err instanceof Error ? err.message : "Failed to upload tracks"); } finally { uploadState.value = "idle"; }}
/** * Stops syncing: clears the sync state so new local tracks are no longer * auto-uploaded. Does NOT disable the input source or remove the remote * tracks — they stay in the output and remain visible (and deletable) in * the remote tracks list. */async function stopSyncing() { setError(null);
const scheme = syncScheme.value; if (!scheme) return;
// Clear sync state. The input source stays enabled so remote tracks remain // visible and deletable. syncScheme.value = null; await IDB.del(SYNC_SCHEME_KEY);}
/** * Triggers the OAuth flow to get a fresh access token for the current sync * scheme. Called automatically when an expired token is detected during a * delete or upload operation. Bypasses the `consult()` cache (which may * still report "yes" right after expiry) and goes straight to re-authorisation. */function reconnectSync() { const scheme = syncScheme.value; if (!scheme) return;
const uploadElement = uploadConfigurator.uploaders()[scheme]; if (!uploadElement?.authorize) { setError( `Upload component "${ SCHEME_LABELS[scheme] ?? scheme }" does not support reconnection.`, ); return; }
setError("Token expired. Reconnecting…");
const url = new URL(location.href); url.searchParams.set("uploading", scheme); history.replaceState({}, "", url);
uploadElement.authorize();}
/** * @param {File[]} files */async function cacheFiles(files) { setError(null); uploadState.value = "caching"; try { const uris = await Promise.all( files.map((file) => inputConfigurator.cacheBlob(file)), ); files.forEach((file, i) => { const uri = uris[i]; ephemeralNames.set(uri, file.name); const cid = cidFromUri(uri); if (cid) cidNames.set(cid, file.name); }); // Persist the CID → filename map so labels survive upload and reloads. await IDB.set(CID_NAMES_KEY, Object.fromEntries(cidNames)); namesVersion.value++; const now = new Date().toISOString(); const existingTracks = await Output.data(outputOrchestrator.tracks); const existingUris = new Set(existingTracks.map((t) => t.uri)); const newUris = uris.filter((uri) => !existingUris.has(uri)); await outputOrchestrator.tracks.save([ ...existingTracks, ...newUris.map((uri) => { /** @type {Track} */ const track = { $type: "sh.diffuse.output.track", id: TID.now(), createdAt: now, updatedAt: now, ephemeral: true, uri, }; return track; }), ]);
// If in sync mode, auto-upload the newly cached tracks. if (syncScheme.value) { await syncUpload(); } } catch (err) { setError(err instanceof Error ? err.message : "Failed to cache files"); } finally { // Only reset if we're still in "caching" state — if syncUpload ran, it // manages its own upload state ("uploading" → "idle"). if (uploadState.value === "caching") uploadState.value = "idle"; }}
/** * @param {DataTransferItem[]} items * @param {File[]} directFiles * @returns {Promise<File[]>} */async function collectFiles(items, directFiles) { // `isAudioFile` matches on the filename extension. This is deliberate: it // relies on the canonical `~/components/input/common.js` list and works even // when Firefox reports an empty `File.type` for OS-dragged files. const files = directFiles.filter((file) => isAudioFile(file.name));
await Promise.all( items.map(async (item) => { if (item.kind !== "file") return;
const entry = item.webkitGetAsEntry?.(); if (entry?.isDirectory) { const dirFiles = await readDirectoryEntry( /** @type {FileSystemDirectoryEntry} */ (entry), ); files.push(...dirFiles.filter((file) => isAudioFile(file.name))); } else { // `getAsFile()` can return `null` on Firefox for OS-dragged files, so // a `File` arriving here is best-effort; direct files already came via // `dataTransfer.files`. Only add when it's usable. const file = item.getAsFile(); if (file && isAudioFile(file.name)) files.push(file); } }), );
return files;}
/** * @param {FileSystemDirectoryEntry} dir * @returns {Promise<File[]>} */async function readDirectoryEntry(dir) { const reader = dir.createReader();
return new Promise((resolve, reject) => { /** @type {File[]} */ const files = [];
const readBatch = () => { reader.readEntries(async (entries) => { if (entries.length === 0) { resolve(files); return; }
await Promise.all( entries.map(async (entry) => { if (entry.isDirectory) { const nested = await readDirectoryEntry( /** @type {FileSystemDirectoryEntry} */ (entry), ); files.push(...nested); } else { const file = await new Promise( /** @param {(f: File) => void} res */ (res, rej) => /** @type {FileSystemFileEntry} */ (entry).file(res, rej), ); if (file.type.startsWith("audio/")) files.push(file); } }), );
readBatch(); }, reject); };
readBatch(); });}
////////////////////////////////////////////// AUTO-UPLOAD ON LOAD////////////////////////////////////////////
// If we're in sync mode on page load (persisted from a previous session) and// there are ephemeral tracks that haven't been uploaded yet, upload them now.// Skip if resumeUpload is already handling it (detected via the OAuth callback).{ if (syncScheme.value && uploadState.get() !== "uploading") { const tracks = await Output.data(outputOrchestrator.tracks); const hasEphemeral = tracks.some((t) => t.uri.startsWith("ephemeral+cache://") ); if (hasEphemeral) { syncUpload(); } }}
foundation.ready();