A music player that connects to your cloud/distributed storage. diffuse.sh
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377import foundation from "~/common/foundation.js";import { effect } from "~/common/signal.js";import { CommandCore } from "~/vendor/kmenu-core/index.js";import * as Playlist from "~/common/playlist.js";
foundation.setup({ title: "Command Menu | Diffuse" });
// ---------------------------------------------------------------------------// Foundation setup// ---------------------------------------------------------------------------
const [queue, repeatShuffle, output, scope, favourites] = await Promise.all([ foundation.engine.queue(), foundation.engine.repeatShuffle(), foundation.orchestrator.output(), foundation.engine.scope(), foundation.orchestrator.favourites(),]);
// ---------------------------------------------------------------------------// DOM elements// ---------------------------------------------------------------------------
const searchInput = /** @type {HTMLInputElement} */ (document.querySelector("#search"));const optionsList = /** @type {HTMLUListElement} */ (document.querySelector("#kmenu-listbox"));const breadcrumbsEl = /** @type {HTMLElement} */ (document.querySelector("#breadcrumbs"));
// ---------------------------------------------------------------------------// Command menu instance// ---------------------------------------------------------------------------
const menu = new CommandCore({ // At root level, the package searches allOptions (full flattened tree) which // includes submenu children. Restrict to root-only items by filtering out // anything with a parent (i.e. options that belong to a submenu). filter: (options, query) => { if (!query) return options; const rootOnly = options.filter((/** @type {any} */ o) => !o.parent); const q = query.toLowerCase(); return rootOnly.filter( (/** @type {any} */ o) => !o.disabled && (o.label.toLowerCase().includes(q) || o.keywords?.some((/** @type {string} */ k) => k.toLowerCase().includes(q) )), ); },});
// Register DOM elements with CommandCore (for scroll management and focus)menu.getListboxProps().ref(optionsList);menu.getInputProps().ref(searchInput);
// Wire up input eventssearchInput.oninput = (/** @type {Event} */ e) => menu.setInput(/** @type {HTMLInputElement} */ (e.target).value);searchInput.onkeydown = (/** @type {KeyboardEvent} */ e) => menu.getInputProps().onKeyDown(e);
// The package's Escape handler always calls close(), never goBack(). Intercept// at the window level so it works regardless of which element has focus.window.addEventListener("keydown", (/** @type {KeyboardEvent} */ e) => { if (e.key === "Escape" && menu.getState().breadcrumbs.length > 0) { e.preventDefault(); e.stopImmediatePropagation(); menu.goBack(); }});
// ---------------------------------------------------------------------------// Render// ---------------------------------------------------------------------------
function render() { const state = menu.getState();
// Sync input value without disturbing cursor position when focused if (document.activeElement !== searchInput) { searchInput.value = state.input; }
// Breadcrumbs breadcrumbsEl.innerHTML = "";
if (state.breadcrumbs.length > 0) { const home = document.createElement("button"); home.className = "crumb-home"; home.textContent = "Home"; home.onclick = () => { while (menu.getState().breadcrumbs.length > 0) menu.goBack(); }; breadcrumbsEl.appendChild(home);
for (const crumb of state.breadcrumbs) { const span = document.createElement("span"); span.textContent = crumb.label; breadcrumbsEl.appendChild(span); } }
// Options list optionsList.innerHTML = "";
if (state.filtered.length === 0) { const li = document.createElement("li"); li.className = "empty-message"; li.textContent = "No commands found"; optionsList.appendChild(li); return; }
let currentGroup = null;
for (const option of state.filtered) { // Group header if (option.group && option.group !== currentGroup) { currentGroup = option.group; const groupLi = document.createElement("li"); groupLi.className = "group-label"; groupLi.setAttribute("role", "presentation"); groupLi.textContent = option.group; optionsList.appendChild(groupLi); }
const li = document.createElement("li"); li.className = "option";
const props = menu.getOptionProps(/** @type {string} */ (option.id)); props.ref(li); // Register with CommandCore for scroll management
li.setAttribute("id", `kmenu-option-${option.id}`); li.setAttribute("role", "option"); li.setAttribute( "aria-selected", state.activeId === option.id ? "true" : "false", ); if (option.disabled) li.setAttribute("aria-disabled", "true");
const label = document.createElement("span"); label.className = "option-label"; label.textContent = option.label; li.appendChild(label);
const hasChildren = option.children && option.children.length > 0; if (hasChildren) { const chevron = document.createElement("i"); chevron.className = "ph-bold ph-caret-right option-chevron"; chevron.setAttribute("aria-hidden", "true"); li.appendChild(chevron); }
li.onclick = props.onClick; li.onmouseenter = props.onMouseEnter;
optionsList.appendChild(li); }}
// Full re-render only for structural changes (list content changes)for (const event of ["open", "change", "submenu", "back"]) { menu.on(/** @type {any} */ (event), render);}
// Navigation: update only the active highlight without rebuilding the DOMmenu.on("navigate", (/** @type {any} */ e) => { for (const li of optionsList.querySelectorAll("[role=option]")) { li.setAttribute( "aria-selected", li.id === `kmenu-option-${e.activeId}` ? "true" : "false", ); } if (e.activeId) { searchInput.setAttribute( "aria-activedescendant", `kmenu-option-${e.activeId}`, ); } else { searchInput.removeAttribute("aria-activedescendant"); }});
// Re-open the menu immediately on close to keep it always visiblemenu.on("close", () => menu.open());
// ---------------------------------------------------------------------------// Commands — registered reactively via effect()// ---------------------------------------------------------------------------
effect(() => { const nowItem = queue.now(); const isRepeat = repeatShuffle.repeat(); const isShuffle = repeatShuffle.shuffle(); const currentPlaylist = scope.playlist();
const tracksCol = output.tracks.collection(); const now = nowItem && tracksCol.state === "loaded" ? tracksCol.data.find((t) => t.id === nowItem.id) ?? null : null;
const col = output.playlistItems.collection(); const items = col.state === "loaded" ? col.data : [];
const playlistMap = Playlist.gather(items); const playlists = [...playlistMap.values()].sort((a, b) => a.name.localeCompare(b.name) );
const isFav = now && favourites ? favourites.isFavourite(now) : false; const nowLabel = now ? (now.tags?.artist ? `${now.tags.artist} - ${now.tags.title}` : "current track") : null;
menu.registerOptions([ // ------------------------------------------------------------------ // Playback // ------------------------------------------------------------------ { id: "favourite-toggle", label: nowLabel ? isFav ? `Remove "${nowLabel}" from favourites` : `Add "${nowLabel}" to favourites` : "Add now playing to favourites", keywords: ["favourite", "favorite", "like", "heart", "star"], group: "Playback", disabled: !now, action: () => { const item = queue.now(); if (!item) return; const tc = output.tracks.collection(); const track = tc.state === "loaded" ? tc.data.find((t) => t.id === item.id) : undefined; if (track) favourites.toggle(track); }, }, { id: "toggle-repeat", label: isRepeat ? "Disable repeat" : "Enable repeat", keywords: ["repeat", "loop"], group: "Playback", action: () => { repeatShuffle.setRepeat(!repeatShuffle.repeat()); }, }, { id: "toggle-shuffle", label: isShuffle ? "Disable shuffle" : "Enable shuffle", keywords: ["shuffle", "random"], group: "Playback", action: () => { repeatShuffle.setShuffle(!repeatShuffle.shuffle()); }, },
// ------------------------------------------------------------------ // Playlists // ------------------------------------------------------------------ { id: "select-playlist", label: currentPlaylist ? `Playlist: ${currentPlaylist}` : "Select playlist", keywords: ["playlist", "filter", "browse", "queue"], group: "Playlists", children: [ { id: "playlist-all", label: "All tracks", keywords: ["all", "everything", "reset"], action: () => { scope.setPlaylist(undefined); }, }, ...playlists.map((p) => ({ id: `playlist-select-${p.name}`, label: p.name, action: () => { scope.setPlaylist(p.name); }, })), ], }, { id: "create-playlist", label: nowLabel ? `Create playlist with "${nowLabel}"` : "Create playlist", keywords: ["new", "add", "create", "playlist"], group: "Playlists", disabled: !now, action: () => { const item = queue.now(); if (!item) return; const tc = output.tracks.collection(); const track = tc.state === "loaded" ? tc.data.find((t) => t.id === item.id) : undefined; if (!track) return;
const name = prompt("New playlist name:"); if (!name?.trim()) return;
const col = output.playlistItems.collection(); const existing = col.state === "loaded" ? col.data : []; const ts = new Date().toISOString();
output.playlistItems.save([ ...existing, { $type: "sh.diffuse.output.playlistItem", id: crypto.randomUUID(), playlist: name.trim(), criteria: [ { field: "tags.artist", value: /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (track.tags?.artist ?? "")), transformations: ["toLowerCase"], }, { field: "tags.title", value: /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (track.tags?.title ?? "")), transformations: ["toLowerCase"], }, ], createdAt: ts, updatedAt: ts, }, ]); }, }, { id: "remove-playlist", label: "Remove playlist", keywords: ["delete", "remove", "playlist"], group: "Playlists", disabled: playlists.length === 0, children: playlists.map((p) => ({ id: `playlist-remove-${p.name}`, label: p.name, children: [ { id: `playlist-remove-${p.name}-confirm`, label: `Confirm: remove "${p.name}"`, keywords: ["yes", "confirm", "delete"], action: () => { const col = output.playlistItems.collection(); const existing = col.state === "loaded" ? col.data : []; output.playlistItems.save( existing.filter((item) => item.playlist !== p.name), ); }, }, ], })), }, ]);
// Re-render after options change so labels (repeat/shuffle/now-playing) // stay in sync without relying on the navigate event. render();});
// ---------------------------------------------------------------------------// Open and signal ready// ---------------------------------------------------------------------------
menu.open();
foundation.ready();