} 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`
${opt.label}
${isActive
? html`Active`
: nothing}
${detail}
${isActive
? nothing
: isActivated
? html`
`
: connectUrl
? html`
Configure
`
: nothing}
`;
})}
`,
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();