diff --git a/deno.jsonc b/deno.jsonc --- a/deno.jsonc +++ b/deno.jsonc @@ -113,6 +113,8 @@ "./components/configurator/metadata/worker.js": "./src/components/configurator/metadata/worker.js", "./components/configurator/output/element.js": "./src/components/configurator/output/element.js", "./components/configurator/scrobbles/element.js": "./src/components/configurator/scrobbles/element.js", + "./components/configurator/upload/element.js": "./src/components/configurator/upload/element.js", + "./components/configurator/upload/worker.js": "./src/components/configurator/upload/worker.js", "./components/engine/audio/element.js": "./src/components/engine/audio/element.js", "./components/engine/queue/element.js": "./src/components/engine/queue/element.js", "./components/engine/queue/worker.js": "./src/components/engine/queue/worker.js", @@ -192,6 +194,9 @@ "./components/transformer/output/refiner/initial-contents/element.js": "./src/components/transformer/output/refiner/initial-contents/element.js", "./components/transformer/output/refiner/passkey-encryption/element.js": "./src/components/transformer/output/refiner/passkey-encryption/element.js", "./components/transformer/output/string/json/element.js": "./src/components/transformer/output/string/json/element.js", + "./components/upload/dropbox/common.js": "./src/components/upload/dropbox/common.js", + "./components/upload/dropbox/element.js": "./src/components/upload/dropbox/element.js", + "./components/upload/dropbox/worker.js": "./src/components/upload/dropbox/worker.js", // .d.ts "./common/element.d.ts": "./src/common/element.d.ts", @@ -203,6 +208,7 @@ "./components/configurator/input/types.d.ts": "./specs/components/configurator/input/types.d.ts", "./components/configurator/metadata/types.d.ts": "./specs/components/configurator/metadata/types.d.ts", "./components/configurator/output/types.d.ts": "./specs/components/configurator/output/types.d.ts", + "./components/configurator/upload/types.d.ts": "./specs/components/configurator/upload/types.d.ts", "./components/engine/audio/types.d.ts": "./specs/components/engine/audio/types.d.ts", "./components/engine/queue/types.d.ts": "./specs/components/engine/queue/types.d.ts", "./components/input/opensubsonic/types.d.ts": "./specs/components/input/opensubsonic/types.d.ts", @@ -222,6 +228,7 @@ "./components/supplement/types.d.ts": "./specs/components/supplement/types.d.ts", "./components/transformer/output/bytes/automerge/types.d.ts": "./specs/components/transformer/output/bytes/automerge/types.d.ts", "./components/transformer/output/bytes/dasl-sync/types.d.ts": "./specs/components/transformer/output/bytes/dasl-sync/types.d.ts", + "./components/upload/types.d.ts": "./specs/components/upload/types.d.ts", "./definitions/types.d.ts": "./src/definitions/types.d.ts", // .ts diff --git a/specs/components/SPEC.md b/specs/components/SPEC.md --- a/specs/components/SPEC.md +++ b/specs/components/SPEC.md @@ -41,6 +41,11 @@ Various ways to fetch metadata for tracks. Must adhere to types defined in `metadata/types.d.ts` +### Upload + +Upload components handle uploading audio files to cloud storage (e.g. Dropbox) and deleting them again. They are paired with input components by scheme: an upload component's `SCHEME` matches the input component that can resolve the URIs it produces. `upload()` returns a URI carrying that scheme, which is stored on a track; the matching input component can then resolve it, and `delete()` the file remotely. Must adhere to types defined in `upload/types.d.ts` + + ### Output Output components hold all the user data: facets, playlist items, settings and tracks. They all have the same surface API. Data is exposed via a signal getter called `collection`, one for each kind of data. These components are also responsible loading and saving that data. The data may be encoded, each component decides for its own what the type of the data is. The subcategory states what that type is and this is reflected in the directory structure these components live in: diff --git a/src/_data/facets.json b/src/_data/facets.json --- a/src/_data/facets.json +++ b/src/_data/facets.json @@ -83,6 +83,13 @@ "desc": "Add Dropbox as an audio source." }, { + "url": "facets/data/file-manager/index.html", + "title": "File Manager", + "category": "Data", + "featured": true, + "desc": "Upload audio files to play them and optionally sync them with your cloud storage. These are automatically added as inputs (aka. sources)." + }, + { "url": "facets/connect/https/index.html", "title": "Connect / HTTPS", "category": "Data", @@ -155,6 +162,14 @@ "category": "Data", "tags": ["base"], "desc": "The default setup for user-data storage output. Adds support for: AT Protocol and S3-compatible storage. For both of these a custom local-first syncing algorithm is used." + }, + { + "url": "facets/data/upload-bundle/index.html", + "title": "Default Upload Bundle", + "kind": "prelude", + "category": "Data", + "tags": ["base"], + "desc": "The default setup for uploading audio files to cloud storage. Adds support for: Dropbox." }, { "url": "facets/data/export-import/index.html", diff --git a/src/common/foundation.js b/src/common/foundation.js --- a/src/common/foundation.js +++ b/src/common/foundation.js @@ -26,6 +26,9 @@ scrobbles: signal( /** @type {import("~/components/configurator/scrobbles/element.js").CLASS | null} */ (null), ), + upload: signal( + /** @type {import("~/components/configurator/upload/element.js").CLASS | null} */ (null), + ), }, engine: { @@ -98,6 +101,7 @@ metadata: configuratorMetadata, input, scrobbles, + upload, }, engine: { @@ -132,6 +136,7 @@ metadata: signals.configurator.metadata.get, input: signals.configurator.input.get, scrobbles: signals.configurator.scrobbles.get, + upload: signals.configurator.upload.get, }, engine: { @@ -236,6 +241,17 @@ i.setAttribute("group", GROUP); return findExistingOrAdd(i, signals.configurator.input); +} + +async function upload() { + const { CLASS: UploadConfigurator } = await import( + "~/components/configurator/upload/element.js" + ); + + const u = new UploadConfigurator(); + u.setAttribute("group", GROUP); + + return findExistingOrAdd(u, signals.configurator.upload); } /** diff --git a/src/components/index.js b/src/components/index.js --- a/src/components/index.js +++ b/src/components/index.js @@ -7,6 +7,7 @@ import * as _ConfiguratorMetadata from "./configurator/metadata/element.js" import * as _ConfiguratorOutput from "./configurator/output/element.js" import * as _ConfiguratorScrobbles from "./configurator/scrobbles/element.js" +import * as _ConfiguratorUpload from "./configurator/upload/element.js" import * as _EngineAudio from "./engine/audio/element.js" import * as _EngineQueue from "./engine/queue/element.js" import * as _EngineRepeatShuffle from "./engine/repeat-shuffle/element.js" @@ -48,6 +49,7 @@ import * as _TransformerOutputRefinerInitialContents from "./transformer/output/refiner/initial-contents/element.js" import * as _TransformerOutputRefinerPasskeyEncryption from "./transformer/output/refiner/passkey-encryption/element.js" import * as _TransformerOutputStringJson from "./transformer/output/string/json/element.js" +import * as _UploadDropbox from "./upload/dropbox/element.js" export const artwork = { audioMetadata: _ArtworkAudioMetadata, @@ -62,6 +64,7 @@ metadata: _ConfiguratorMetadata, output: _ConfiguratorOutput, scrobbles: _ConfiguratorScrobbles, + upload: _ConfiguratorUpload, } export const engine = { @@ -140,4 +143,8 @@ json: _TransformerOutputStringJson, }, }, +} + +export const upload = { + dropbox: _UploadDropbox, } diff --git a/specs/components/upload/types.d.ts b/specs/components/upload/types.d.ts new file mode 100644 --- /dev/null +++ b/specs/components/upload/types.d.ts @@ -0,0 +1,49 @@ +import type { ProxiedActions } from "~/common/worker.d.ts"; + +import type { Track } from "~/definitions/types.d.ts"; +import type { Consult } from "@specs/components/input/types.d.ts"; +import type { DiffuseElement } from "~/common/element.js"; + +export type UploadActions = { + /** + * Check if this uploader or an individual URI can be used. + */ + consult(uriOrScheme: string): Promise; + + /** + * Builds a placeholder track for the scheme's account, used to "add" the + * corresponding input component so it lists the uploaded files. + * + * `scheme` is used by the configurator to route to the correct upload + * component; individual components ignore it (they know their own scheme). + */ + createSource(args: { + scheme: string; + accessToken: string; + directoryPath: string; + }): Promise; + + /** + * Delete an uploaded track. + */ + delete(uri: string): Promise; + + /** + * Upload a track. + */ + upload(args: { file: File; uri: string; path?: string }): Promise; +}; + +export type UploadElement = + & DiffuseElement + & UploadSchemeProvider + & ProxiedActions + & { + /** + * Triggers the OAuth flow for this upload component, if applicable. + * Not all upload components require OAuth. + */ + authorize?(): void; + }; + +export type UploadSchemeProvider = { SCHEME: string }; diff --git a/src/styles/diffuse/facet.css b/src/styles/diffuse/facet.css --- a/src/styles/diffuse/facet.css +++ b/src/styles/diffuse/facet.css @@ -186,8 +186,9 @@ } &.button--danger { - border-color: var(--accent-twist-4); - color: var(--accent-twist-4); + background-color: var(--accent-twist-4); + border-color: transparent; + color: var(--bg-color); } &.button--plain { diff --git a/specs/components/configurator/upload/types.d.ts b/specs/components/configurator/upload/types.d.ts new file mode 100644 --- /dev/null +++ b/specs/components/configurator/upload/types.d.ts @@ -0,0 +1,3 @@ +import type {UploadActions} from "@specs/components/upload/types.d.ts" + +export type Actions = UploadActions; diff --git a/src/components/configurator/upload/element.js b/src/components/configurator/upload/element.js new file mode 100644 --- /dev/null +++ b/src/components/configurator/upload/element.js @@ -0,0 +1,64 @@ +import { defineElement, DiffuseElement } from "~/common/element.js"; + +/** + * @import {ProxiedActions, Tunnel} from "~/common/worker.d.ts" + * @import {UploadElement} from "@specs/components/upload/types.d.ts" + * @import {Actions} from "@specs/components/configurator/upload/types.d.ts" + */ + +/** + * @typedef {{ element: UploadElement, tunnel: Tunnel, worker: Worker | SharedWorker }} Upload + */ + +//////////////////////////////////////////// +// ELEMENT +//////////////////////////////////////////// + +/** + * @implements {ProxiedActions} + */ +class UploadConfigurator extends DiffuseElement { + static NAME = "diffuse/configurator/upload"; + static WORKER_URL = "components/configurator/upload/worker.js"; + + constructor() { + super(); + + /** @type {ProxiedActions} */ + const proxy = this.workerProxy(); + + this.consult = proxy.consult; + this.upload = proxy.upload; + this.delete = proxy.delete; + this.createSource = proxy.createSource; + } + + // WORKERS + + /** + * @override + */ + dependencies() { + return this.uploaders(); + } + + uploaders() { + return Object.fromEntries( + Array.from(this.children).map((element) => { + const upload = /** @type {UploadElement} */ (element); + return [upload.SCHEME, upload]; + }), + ); + } +} + +export default UploadConfigurator; + +//////////////////////////////////////////// +// REGISTER +//////////////////////////////////////////// + +export const CLASS = UploadConfigurator; +export const NAME = "dc-upload"; + +defineElement(NAME, CLASS); diff --git a/src/components/configurator/upload/worker.js b/src/components/configurator/upload/worker.js new file mode 100644 --- /dev/null +++ b/src/components/configurator/upload/worker.js @@ -0,0 +1,100 @@ +import * as URI from "fast-uri"; +import { ostiary, rpc, workerProxy } from "~/common/worker.js"; + +/** + * @import {UploadActions} from "@specs/components/upload/types.d.ts" + * @import {ActionsWithTunnel, ProxiedActions} from "~/common/worker.d.ts" + * @import {Actions} from "@specs/components/configurator/upload/types.d.ts" + */ + +//////////////////////////////////////////// +// ACTIONS +//////////////////////////////////////////// + +/** + * @type {ActionsWithTunnel['consult']} + */ +export async function consult({ data, ports }) { + const fileUriOrScheme = data; + const scheme = fileUriOrScheme.includes(":") + ? URI.parse(fileUriOrScheme).scheme || fileUriOrScheme + : fileUriOrScheme; + + const upload = grabUploader(scheme, ports); + if (!upload) { + return { supported: false, reason: "Unsupported scheme" }; + } + + return await upload.consult(fileUriOrScheme); +} + +/** + * @type {ActionsWithTunnel['createSource']} + */ +export async function createSource({ data, ports }) { + const upload = grabUploader(data.scheme, ports); + if (!upload) { + throw new Error(`Unsupported scheme: ${data.scheme}`); + } + + return await upload.createSource(data); +} + +/** + * @type {ActionsWithTunnel['delete']} + */ +export async function deleteFn({ data, ports }) { + const uri = data; + const scheme = uri.split(":", 1)[0]; + const upload = grabUploader(scheme, ports); + if (!upload) { + throw new Error(`Unsupported scheme: ${scheme}`); + } + + await upload.delete(uri); +} + +/** + * @type {ActionsWithTunnel['upload']} + */ +export async function upload({ data, ports }) { + const scheme = data.uri.split(":", 1)[0]; + const upload = grabUploader(scheme, ports); + if (!upload) { + throw new Error(`Unsupported scheme: ${scheme}`); + } + + return await upload.upload(data); +} + +//////////////////////////////////////////// +// ⚑️ +//////////////////////////////////////////// + +ostiary((context) => { + rpc(context, { + consult, + upload, + delete: deleteFn, + createSource, + }); +}); + +//////////////////////////////////////////// +// πŸ› οΈ +//////////////////////////////////////////// + +/** + * @param {string} scheme + * @param {Record} ports + * @returns {ProxiedActions | null} + */ +function grabUploader(scheme, ports) { + const port = ports[scheme]; + if (!port) return null; + + return workerProxy(() => { + port.start(); + return port; + }); +} diff --git a/src/components/orchestrator/process-tracks/element.js b/src/components/orchestrator/process-tracks/element.js --- a/src/components/orchestrator/process-tracks/element.js +++ b/src/components/orchestrator/process-tracks/element.js @@ -211,7 +211,12 @@ }); if (result) { - await this.output.tracks.save(mergeById(cachedTracks, result)); + // Re-read the current output tracks instead of using the stale + // `cachedTracks` snapshot from the start of processing. If a track + // was deleted from the output while processing was in-flight, it + // won't be in `currentTracks`, so `mergeById` won't bring it back. + const currentTracks = await data(this.output.tracks); + await this.output.tracks.save(mergeById(currentTracks, result)); } // Fin diff --git a/src/components/upload/dropbox/common.js b/src/components/upload/dropbox/common.js new file mode 100644 --- /dev/null +++ b/src/components/upload/dropbox/common.js @@ -0,0 +1,124 @@ +import { parseURI } from "~/components/input/dropbox/common.js"; + +/** + * @import { Account } from "~/components/input/dropbox/common.js" + */ + +//////////////////////////////////////////// +// DROPBOX API +//////////////////////////////////////////// + +/** + * Upload a file to Dropbox. Uses the content-upload endpoint with the + * file's bytes as the request body. + * + * @param {string} accessToken + * @param {string} destinationPath Full Dropbox path (e.g. "/Music/song.mp3"). + * @param {File} file + * @returns {Promise<{ path_lower: string; name: string } | null>} The uploaded file metadata, or null on failure. + */ +export async function uploadFile(accessToken, destinationPath, file) { + const resp = await fetch( + "https://content.dropboxapi.com/2/files/upload", + { + method: "POST", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/octet-stream", + "Dropbox-API-Arg": JSON.stringify({ + path: destinationPath, + mode: "add", + autorename: true, + mute: true, + }).replace(/[^\x00-\x7F]/g, (ch) => + "\\u" + ("0000" + ch.charCodeAt(0).toString(16)).slice(-4) + ), + }, + body: file, + }, + ); + + if (!resp.ok) { + /** @type {{ error?: { ".tag"?: string } } | null} */ + const body = await resp.json().catch(() => null); + if (body?.error?.[".tag"] === "expired_access_token") { + throw new Error("Dropbox access token has expired. Please reconnect."); + } + return null; + } + + /** @type {{ path_lower: string; name: string }} */ + const data = await resp.json(); + return data; +} + +/** + * Delete a file from Dropbox. + * + * @param {string} accessToken + * @param {string} filePath Full Dropbox path (e.g. "/Music/song.mp3"). + * @returns {Promise} + */ +export async function deleteFile(accessToken, filePath) { + const resp = await fetch( + "https://api.dropboxapi.com/2/files/delete_v2", + { + method: "POST", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ path: filePath }), + }, + ); + + if (!resp.ok) { + /** @type {{ error?: { ".tag"?: string } } | null} */ + const body = await resp.json().catch(() => null); + if (body?.error?.[".tag"] === "expired_access_token") { + throw new Error("Dropbox access token has expired. Please reconnect."); + } + throw new Error(`Failed to delete "${filePath}" from Dropbox`); + } + + return true; +} + +//////////////////////////////////////////// +// PATH HELPERS +//////////////////////////////////////////// + +/** + * Resolve the destination path for an upload. + * + * If `path` is provided it is used as-is (a full Dropbox path). + * Otherwise the file is placed inside the account's directory. + * + * @param {Account} account + * @param {File} file + * @param {string} [path] + * @returns {string} + */ +export function resolveDestinationPath(account, file, path) { + if (path) return path; + + const dir = account.directoryPath === "/" + ? "" + : account.directoryPath; + return `${dir}/${file.name}`; +} + +/** + * Extract the account info from an account URI. + * Throws if the URI is not a valid Dropbox account URI. + * + * @param {string} uri + * @returns {Account} + */ +export function accountFromURI(uri) { + const parsed = parseURI(uri); + if (!parsed) { + throw new Error(`Invalid Dropbox URI: ${uri}`); + } + return { accessToken: parsed.accessToken, directoryPath: parsed.directoryPath }; +} diff --git a/src/components/upload/dropbox/element.js b/src/components/upload/dropbox/element.js new file mode 100644 --- /dev/null +++ b/src/components/upload/dropbox/element.js @@ -0,0 +1,75 @@ +import { defineElement, DiffuseElement } from "~/common/element.js"; +import { DEFAULT_APP_KEY, SCHEME } from "~/components/input/dropbox/constants.js"; + +/** + * @import {UploadActions, UploadSchemeProvider} from "@specs/components/upload/types.d.ts" + * @import {ProxiedActions} from "~/common/worker.d.ts" + */ + +//////////////////////////////////////////// +// ELEMENT +//////////////////////////////////////////// + +/** + * @implements {ProxiedActions} + * @implements {UploadSchemeProvider} + */ +class DropboxUpload extends DiffuseElement { + static NAME = "diffuse/upload/dropbox"; + static WORKER_URL = "components/upload/dropbox/worker.js"; + + SCHEME = SCHEME; + + /** @type {string} */ + appKey = DEFAULT_APP_KEY; + + static observedAttributes = ["app-key"]; + + /** + * @override + * @param {string} name + * @param {string} old + * @param {string} next + */ + attributeChangedCallback(name, old, next) { + super.attributeChangedCallback(name, old, next); + if (name === "app-key" && next !== null) this.appKey = next; + } + + constructor() { + super(); + + /** @type {ProxiedActions} */ + this.proxy = this.workerProxy(); + + this.consult = this.proxy.consult; + this.upload = this.proxy.upload; + this.delete = this.proxy.delete; + this.createSource = this.proxy.createSource; + } + + // πŸ› οΈ + + authorize() { + localStorage.setItem("oauth/callback/redirect_path", location.pathname + location.search); + + const params = new URLSearchParams({ + response_type: "token", + client_id: this.appKey, + redirect_uri: location.origin + "/oauth/callback/", + }); + + location.assign(`https://www.dropbox.com/oauth2/authorize?${params}`); + } +} + +export default DropboxUpload; + +//////////////////////////////////////////// +// REGISTER +//////////////////////////////////////////// + +export const CLASS = DropboxUpload; +export const NAME = "du-dropbox"; + +defineElement(NAME, CLASS); diff --git a/src/components/upload/dropbox/worker.js b/src/components/upload/dropbox/worker.js new file mode 100644 --- /dev/null +++ b/src/components/upload/dropbox/worker.js @@ -0,0 +1,97 @@ +import * as TID from "@atcute/tid"; +import { ostiary, rpc } from "~/common/worker.js"; +import { + buildURI, + checkAccessCached, + parseURI, +} from "~/components/input/dropbox/common.js"; +import { + accountFromURI, + deleteFile, + resolveDestinationPath, + uploadFile, +} from "./common.js"; + +/** + * @import { UploadActions as Actions } from "@specs/components/upload/types.d.ts"; + */ + +//////////////////////////////////////////// +// ACTIONS +//////////////////////////////////////////// + +/** + * @type {Actions['consult']} + */ +export async function consult(fileUriOrScheme) { + if (!fileUriOrScheme.includes(":")) { + return { supported: true, consult: "undetermined" }; + } + + const parsed = parseURI(fileUriOrScheme); + if (!parsed) return { supported: true, consult: "undetermined" }; + + const accessible = await checkAccessCached(parsed.accessToken); + return { supported: true, consult: accessible }; +} + +/** + * @type {Actions['upload']} + */ +export async function upload({ file, uri, path }) { + const account = accountFromURI(uri); + const destinationPath = resolveDestinationPath(account, file, path); + + const uploaded = await uploadFile( + account.accessToken, + destinationPath, + file, + ); + + if (!uploaded) { + throw new Error(`Failed to upload "${file.name}" to Dropbox`); + } + + return buildURI(account, uploaded.path_lower); +} + +/** + * @type {Actions['delete']} + */ +export async function deleteFn(uri) { + const parsed = parseURI(uri); + if (!parsed || parsed.path === "/") { + throw new Error(`Invalid Dropbox file URI: ${uri}`); + } + + await deleteFile(parsed.accessToken, parsed.path); +} + +/** + * @type {Actions['createSource']} + */ +export async function createSource({ accessToken, directoryPath }) { + const uri = buildURI({ accessToken, directoryPath }); + const now = new Date().toISOString(); + return { + $type: "sh.diffuse.output.track", + id: TID.now(), + createdAt: now, + updatedAt: now, + kind: "placeholder", + uri, + }; +} + +//////////////////////////////////////////// +// ⚑️ +//////////////////////////////////////////// + +ostiary((context) => { + rpc(context, { + consult, + upload, + delete: deleteFn, + createSource, + }); +}); diff --git a/src/facets/data/file-manager/index.html b/src/facets/data/file-manager/index.html new file mode 100644 --- /dev/null +++ b/src/facets/data/file-manager/index.html @@ -0,0 +1,12 @@ + + +
+ + diff --git a/src/facets/data/file-manager/index.inline.js b/src/facets/data/file-manager/index.inline.js new file mode 100644 --- /dev/null +++ b/src/facets/data/file-manager/index.inline.js @@ -0,0 +1,1078 @@ +import * as TID from "@atcute/tid"; +import * as IDB from "idb-keyval"; +import { html, 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 { SCHEME as SCHEME_DROPBOX } from "~/components/input/dropbox/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" + */ + +/** Human-readable labels for upload component schemes. */ +/** @type {Record} */ +const SCHEME_LABELS = { + [SCHEME_DROPBOX]: "Dropbox", +}; + +/** 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"; + +foundation.setup({ title: "File Manager | Diffuse" }); + +//////////////////////////////////////////// +// 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(); + +/** Bumped whenever `ephemeralNames` is updated so the reactive effect re-fires. */ +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; 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 | null) => void }} + */ +function setup({ title, description, leftContent, rightContent }) { + const main = document.querySelector("main"); + if (!main) throw new Error("No
element"); + + litRender( + html` +
+ +

${title}

+ ${description} + ${leftContent} +
+
+ ${rightContent} + + + +
+ `, + 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 | null} message */ + const setError = (message) => { + cardErrorEl.hidden = message === null; + cardErrorEl.textContent = message; + }; + + /** @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, onRemove }, index) => + html` +
  • +
    + ${name} +
    + + +
  • + `, + )} + `, + listEl, + ); +} + +const { setLocalItems, setRemoteItems, setError } = setup({ + title: "File Manager", + description: html` +

    Upload audio files to play them and optionally sync them with your cloud storage. These are automatically added as inputs (aka. sources).

    + `, + + leftContent: html` +
    + + + + + +
    + `, + + rightContent: html` + + `, +}); + +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); + const items = Array.from(dragEvent.dataTransfer?.items ?? []); + const files = await collectFiles(items); + if (files.length === 0) return; + + await cacheFiles(files); +}); + +stopSyncBtn?.addEventListener("click", () => stopSyncing()); + +//////////////////////////////////////////// +// OAUTH CALLBACK +//////////////////////////////////////////// + +// Detect the OAuth callback: if `?uploading=` is in the URL and +// `#access_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 accessToken = hashParams.get("access_token"); + + // Clean URL: remove the `uploading` param and the hash, keep everything else. + url.searchParams.delete("uploading"); + url.hash = ""; + history.replaceState({}, "", url); + + if (accessToken) { + // Don't await β€” let the page render while the upload runs. + resumeUpload(uploadingScheme, accessToken); + } 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 () => { + 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); + }), + ); + namesVersion.value++; +})(); + +// Load persisted sync scheme. +{ + const scheme = await IDB.get(SYNC_SCHEME_KEY); + if (scheme) syncScheme.value = scheme; +} + +effect(() => { + // Re-fire when ephemeralNames is updated (it's a plain Map, not a signal). + 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). + const localEntries = tracks + .filter((t) => t.uri.startsWith("ephemeral+cache://")) + .map((t) => ({ + label: ephemeralNames.get(t.uri) ?? t.uri.split("://")[1], + uri: t.uri, + })) + .sort((a, b) => + a.label.localeCompare(b.label, undefined, { sensitivity: "base" }) + ); + + setLocalItems( + localEntries.map(({ label, uri }) => ({ + name: label, + onRemove: () => removeLocalEntry(uri), + })), + ); + + // Remote tracks: tracks from the selected upload method's input only. + // When no upload method is selected, no remote tracks are shown. + const scheme = syncScheme.get(); + const remoteEntries = scheme + ? tracks + .filter( + (t) => + t.uri.startsWith(scheme + "://") && + t.kind !== "placeholder", + ) + .map((t) => ({ + label: trackLabel(t.uri), + uri: t.uri, + })) + .sort((a, b) => + a.label.localeCompare(b.label, undefined, { sensitivity: "base" }) + ) + : []; + + setRemoteItems( + remoteEntries.map(({ label, uri }) => ({ + name: label, + onRemove: () => removeRemoteEntry(uri), + })), + ); +}); + +//////////////////////////////////////////// +// 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 isBusy = uploadState.get() !== "idle"; + if (uploadIndicator) uploadIndicator.hidden = !isBusy; + + 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 ..." + : "Connect storage"; + + litRender( + html` + ${uploadEntries.map(([scheme, element]) => + html` + + ` + )} + `, + 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); + await processOrchestrator.process(); +}); + +//////////////////////////////////////////// +// 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; +} + +/** + * 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 */ +async function removeLocalEntry(uri) { + 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); + 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 + */ +async function removeRemoteEntry(uri) { + setError(null); + try { + // Delete the file from the cloud first. + await uploadConfigurator.delete(uri); + + // 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 with their original names. + // 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 name = ephemeralNames.get(track.uri) ?? blob?.name ?? "audio"; + 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(); + } + } +} + +/** + * Starts the sync flow for the selected upload component. Enters sync mode, + * then uploads all ephemeral tracks. If an existing input source for this + * scheme already exists (enabled or disabled), uploads directly to it + * (re-enabling if necessary). Otherwise, triggers the OAuth flow (adding + * `?uploading=` to the URL so we can resume after the redirect). + * + * @param {string} scheme + * @param {UploadElement} uploadElement + */ +async function startUpload(scheme, uploadElement) { + setError(null); + + // Enter sync mode. + syncScheme.value = scheme; + await IDB.set(SYNC_SCHEME_KEY, scheme); + + 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 uploadTracks(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 uploadTracks(disabledSources[0].uri); + return; + } + } + + // No existing source β€” authenticate first. Mark the URL so we can resume + // the upload flow after the OAuth redirect. + if (!uploadElement.authorize) { + setError(`Upload component "${SCHEME_LABELS[scheme] ?? scheme}" does not support authorization.`); + return; + } + + 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 access + * 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} accessToken + */ +async function resumeUpload(scheme, accessToken) { + 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, + accessToken, + directoryPath: UPLOAD_DIRECTORY, + }); + const uri = placeholderTrack.uri; + + // Upload ephemeral tracks if any. + if (ephemeralTracks.length > 0) { + // Reconstruct Files from the IDB-cached blobs with their original names. + // 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 name = ephemeralNames.get(track.uri) ?? blob?.name ?? "audio"; + 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) => { + ephemeralNames.set(uris[i], file.name); + }); + 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 + * @returns {Promise} + */ +async function collectFiles(items) { + const files = /** @type {File[]} */ ([]); + + 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); + } else { + const file = item.getAsFile(); + if (file?.type.startsWith("audio/")) files.push(file); + } + }), + ); + + return files; +} + +/** + * @param {FileSystemDirectoryEntry} dir + * @returns {Promise} + */ +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(); diff --git a/src/facets/data/file-manager/styles.css b/src/facets/data/file-manager/styles.css new file mode 100644 --- /dev/null +++ b/src/facets/data/file-manager/styles.css @@ -0,0 +1,75 @@ +@import "../../../styles/diffuse/facet.css"; + +.file-section { + margin-top: var(--space-md); +} + +.file-section__title { + color: oklch(from var(--text-color) l c h / 0.55); + font-size: var(--fs-xs); + font-weight: 600; + letter-spacing: var(--tracking-wider); + margin: 0 0 var(--space-xs); + text-transform: uppercase; +} + +.file-list { + display: flex; + flex-direction: column; + gap: var(--space-xs); + list-style: none; + margin: 0; + padding: 0; +} + +.file-item { + align-items: center; + display: flex; + font-size: var(--fs-sm); + gap: var(--space-xs); + margin-left: 0; +} + +.file-item__info { + display: flex; + flex-direction: column; + flex: 1; + gap: var(--space-3xs); + min-width: 0; +} + +.file-item__name { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dropzone { + align-items: center; + border: 2px dashed var(--border-color); + border-radius: var(--radius-md); + color: oklch(from var(--text-color) l c h / 0.55); + cursor: pointer; + display: flex; + flex-direction: column; + font-size: var(--fs-sm); + gap: var(--space-2xs); + justify-content: center; + padding: var(--space-md); + transition: + background-color 150ms, + border-color 150ms, + color 150ms; + + &.dropzone--active { + background-color: var(--form-color); + border-color: var(--accent); + color: var(--text-color); + } +} + +.upload-indicator { + color: var(--text-color); + font-size: var(--fs-sm); +} diff --git a/src/facets/data/upload-bundle/index.html b/src/facets/data/upload-bundle/index.html new file mode 100644 --- /dev/null +++ b/src/facets/data/upload-bundle/index.html @@ -0,0 +1,1 @@ + diff --git a/src/facets/data/upload-bundle/index.inline.js b/src/facets/data/upload-bundle/index.inline.js new file mode 100644 --- /dev/null +++ b/src/facets/data/upload-bundle/index.inline.js @@ -0,0 +1,30 @@ +import foundation from "~/common/foundation.js"; +import { effect } from "~/common/signal.js"; + +import { NAME as DROPBOX_NAME } from "~/components/upload/dropbox/element.js"; + + +/** + * @import UploadConfigurator from "~/components/configurator/upload/element.js" + */ + +/** + * Setup DOM elements when needed. + */ +effect(() => { + const upload = foundation.signals.configurator.upload(); + if (!upload) return; + + dropbox(upload); +}); + +//////////////////////////////////////////// +// DROPBOX +//////////////////////////////////////////// + +/** + * @param {UploadConfigurator} upload + */ +export function dropbox(upload) { + upload.append(document.createElement(DROPBOX_NAME)); +}