A music player that connects to your cloud/distributed storage. diffuse.sh
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413import { html, nothing, render as litRender } from "lit-html";
import * as Output from "~/common/output.js";import foundation from "~/common/foundation.js";import { effect, signal } from "~/common/signal.js";import { data as facetsData } from "~/facets/index.js";
/** * @typedef {{ id: string, label: string, element: import("@specs/components/output/types.d.ts").OutputElement }} OutputOption */
foundation.setup({ title: "Your data | Diffuse" });
////////////////////////////////////////////// SETUP////////////////////////////////////////////
const outputOrchestrator = await foundation.orchestrator.output();await customElements.whenDefined(outputOrchestrator.localName);
// The output configurator can't find custom outputs (added by the// output-bundle prelude) during its initial connectedCallback, so it// re-loads the selected output via `loadSelected()` which is async.// Wait for that to settle before rendering the UI, otherwise// `selected()` is still null here and the active-method badge would be// wrong.if (outputOrchestrator.hasSelected() && !outputOrchestrator.selected()) { await new Promise((resolve) => { const stop = effect(() => { if (outputOrchestrator.selected()) { stop(); resolve(undefined); } }); });}
/** @type {Record<string, string>} */const ICONS = { "Local": "devices", "AT Protocol (Public)": "at", "AT Protocol (Space)": "at", "S3": "amazon-logo", "Dropbox": "dropbox-logo",};
/** * Connect facet URLs flagged `incomplete` in `_data/facets.json`. These * methods are hidden from the list unless they're the active one, so an * incomplete method stays visible (with its active badge) while in use. * * @type {Set<string>} */const INCOMPLETE_CONNECT = new Set( facetsData .filter((f) => f.url.startsWith("facets/connect/") && f.incomplete) .map((f) => f.url),);
////////////////////////////////////////////// STATE////////////////////////////////////////////
/** @type {import("~/common/signal.d.ts").Signal<OutputOption[]>} */const options = signal(/** @type {OutputOption[]} */ ([]));
/** * Incremented when a custom element for an activated method finishes * upgrading. The render effects read this so they re-run and can then * access `element.tracks.collection()` (which isn't available before * the element is upgraded). */const refresh = signal(0);
options.value = await outputOrchestrator.options();
const defaultId = outputOrchestrator.outputConfigurator.getAttribute( "default",);
////////////////////////////////////////////// UI ELEMENTS////////////////////////////////////////////
const list = /** @type {HTMLElement} */ (document.querySelector("#userdata-list"));const emptyEl = /** @type {HTMLElement} */ (document.querySelector("#userdata-empty"));const migrateDivider = /** @type {HTMLElement} */ (document.querySelector("#migrate-divider"));const migrateSection = /** @type {HTMLElement} */ (document.querySelector("#migrate-section"));const migrateFrom = /** @type {HTMLSelectElement} */ (document.querySelector("#migrate-from"));const migrateTo = /** @type {HTMLSelectElement} */ (document.querySelector("#migrate-to"));const migrateBtn = /** @type {HTMLButtonElement} */ (document.querySelector("#migrate-btn"));const migrateStatus = /** @type {HTMLElement} */ (document.querySelector("#migrate-status"));
////////////////////////////////////////////// ELEMENT UPGRADE WATCHER////////////////////////////////////////////
/** * Tracks which custom element names have already been scheduled for a * `whenDefined` callback, so we don't schedule duplicates. * * @type {Set<string>} */const scheduled = new Set();
/** * When a method is activated, the output-bundle lazily imports its * custom element. We schedule a `whenDefined` callback for each newly * activated method and bump `refresh` when it resolves, so the render * effect can then safely read the element's collection signals. */effect(() => { const opts = options.get(); const activatedSet = outputOrchestrator.activated();
for (const opt of opts) { const name = opt.element.localName; if ( activatedSet.has(opt.id) && !customElements.get(name) && !scheduled.has(name) ) { scheduled.add(name); customElements.whenDefined(name).then(() => { refresh.value++; }); } }});
////////////////////////////////////////////// METHODS LIST////////////////////////////////////////////
/** * @param {OutputOption} opt * @param {Set<string>} activatedSet * @returns {number | null} - track count, or null if not yet loaded */function getTrackCount(opt, activatedSet) { if (!activatedSet.has(opt.id)) return null; const el = opt.element; if (!el || typeof el.tracks !== "object" || !el.tracks) return null; try { const col = el.tracks.collection(); if (col.state === "loaded") return col.data.length; return null; } catch { return null; }}
effect(() => { const opts = options.get(); const selected = outputOrchestrator.selected(); const activatedSet = outputOrchestrator.activated(); refresh.get();
if (opts.length === 0) { list.hidden = true; emptyEl.hidden = false; return; }
list.hidden = false; emptyEl.hidden = true;
const activeId = selected?.id ?? defaultId;
// Don't list methods whose connect facet is `incomplete`, unless that // method is the active one (so the user can still see and switch away // from it). const visibleOpts = opts.filter((opt) => { if (opt.id === activeId) return true; const url = opt.element?.getAttribute("connect"); return url ? !INCOMPLETE_CONNECT.has(url) : true; });
litRender( html` ${visibleOpts.map((opt) => { const isActive = opt.id === activeId; const isActivated = activatedSet.has(opt.id); const trackCount = getTrackCount(opt, activatedSet); const icon = ICONS[opt.label] ?? "database"; const connectUrl = opt.element?.getAttribute("connect");
/** @type {string | typeof nothing} */ let detail; if (!isActivated) { detail = "Not configured"; } else if (trackCount !== null) { detail = `${trackCount} track${trackCount === 1 ? "" : "s"}`; } else { detail = "Loading…"; }
return html` <li class="userdata-item ${isActive ? "userdata-item--active" : ""}" > <i class="ph-fill ph-${icon} userdata-item__icon"></i> <div class="userdata-item__info"> <span class="userdata-item__name"> ${opt.label} ${isActive ? html`<span class="userdata-item__badge">Active</span>` : nothing} </span> <span class="userdata-item__detail">${detail}</span> </div> ${isActive ? nothing : isActivated ? html` <button class="button--outlined button--small" title="Select this method" @click="${() => outputOrchestrator.select(opt.id)}" > <i class="ph-fill ph-check"></i> Select </button> ` : connectUrl ? html` <a class="button button--outlined button--small" title="Configure this method" href="./l/?path=${connectUrl}" > <i class="ph-fill ph-gear"></i> Configure </a> ` : nothing} </li> `; })} `, list, );});
////////////////////////////////////////////// MIGRATION DROPDOWNS////////////////////////////////////////////
/** * @param {HTMLSelectElement} select * @param {OutputOption[]} opts * @param {string} prevValue */function populateDropdown(select, opts, prevValue) { select.innerHTML = "";
if (opts.length === 0) { select.disabled = true; return; }
select.disabled = false; for (const opt of opts) { const option = document.createElement("option"); option.value = opt.id; option.textContent = opt.label; select.append(option); }
const stillAvailable = opts.some((o) => o.id === prevValue); select.value = stillAvailable ? prevValue : (opts[0]?.id ?? "");}
effect(() => { const opts = options.get(); const activatedSet = outputOrchestrator.activated(); refresh.get();
const activatedOpts = opts.filter((opt) => activatedSet.has(opt.id) && customElements.get(opt.element.localName) );
const showMigrate = activatedOpts.length >= 2; migrateDivider.hidden = !showMigrate; migrateSection.hidden = !showMigrate;
if (!showMigrate) return;
const prevFrom = migrateFrom.value; const prevTo = migrateTo.value;
populateDropdown(migrateFrom, activatedOpts, prevFrom); populateDropdown(migrateTo, activatedOpts, prevTo);
updateMigrateBtn();});
function updateMigrateBtn() { const fromId = migrateFrom.value; const toId = migrateTo.value; migrateBtn.disabled = !fromId || !toId || fromId === toId;}
migrateFrom.addEventListener("change", updateMigrateBtn);migrateTo.addEventListener("change", updateMigrateBtn);
////////////////////////////////////////////// MIGRATION ACTION////////////////////////////////////////////
/** * @param {string} message * @param {"success" | "error" | null} type */function showMigrateStatus(message, type) { migrateStatus.textContent = message; migrateStatus.className = type ? `status status--${type}` : "status"; migrateStatus.hidden = false;}
/** * @param {OutputOption} fromOpt * @returns {Promise<{ tracks: any[], playlistItems: any[], facets: any[], settings: any[] }>} */async function readAllData(fromOpt) { const [tracks, playlistItems, facets, settings] = await Promise.all([ Output.data(fromOpt.element.tracks), Output.data(fromOpt.element.playlistItems), Output.data(fromOpt.element.facets), Output.data(fromOpt.element.settings), ]); return { tracks, playlistItems, facets, settings };}
/** * @param {OutputOption} toOpt * @param {{ tracks: any[], playlistItems: any[], facets: any[], settings: any[] }} data */async function writeAllData(toOpt, data) { await Promise.all([ toOpt.element.tracks.save(data.tracks), toOpt.element.playlistItems.save(data.playlistItems), toOpt.element.facets.save(data.facets), toOpt.element.settings.save(data.settings), ]);}
migrateBtn.addEventListener("click", async () => { const fromId = migrateFrom.value; const toId = migrateTo.value; if (!fromId || !toId || fromId === toId) return;
const fromOpt = options.get().find((o) => o.id === fromId); const toOpt = options.get().find((o) => o.id === toId); if (!fromOpt || !toOpt) return;
if (!fromOpt.element?.tracks || !toOpt.element?.tracks) { showMigrateStatus("One or both methods are not ready.", "error"); return; }
if ( !confirm( `This will replace all data in "${toOpt.label}" with data from "${fromOpt.label}". Continue?`, ) ) { return; }
const btnSpan = /** @type {HTMLElement} */ (migrateBtn.querySelector("span")); const originalLabel = btnSpan.textContent; migrateBtn.disabled = true; btnSpan.textContent = "Migrating…"; showMigrateStatus("Reading data from " + fromOpt.label + "…", null);
try { const data = await readAllData(fromOpt); showMigrateStatus("Writing to " + toOpt.label + "…", null); await writeAllData(toOpt, data);
showMigrateStatus( `Migrated ${data.tracks.length} track${data.tracks.length === 1 ? "" : "s"}, ${data.playlistItems.length} playlist item${data.playlistItems.length === 1 ? "" : "s"}, ${data.facets.length} facet${data.facets.length === 1 ? "" : "s"}, and ${data.settings.length} setting${data.settings.length === 1 ? "" : "s"} from ${fromOpt.label} to ${toOpt.label}.`, "success", ); } catch (err) { console.error("Migration failed:", err); showMigrateStatus( `Migration failed: ${/** @type {Error} */ (err).message}`, "error", ); } finally { btnSpan.textContent = originalLabel; updateMigrateBtn(); }});
////////////////////////////////////////////// 🚀////////////////////////////////////////////
foundation.ready();