diff --git a/deno.jsonc b/deno.jsonc index d516bd29..788811d9 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -42,6 +42,7 @@ "fast-average-color": "npm:fast-average-color@^9.5.0", "fast-uri": "npm:fast-uri@^3.1.0", "idb-keyval": "npm:idb-keyval@^6.2.2", + "kmenu": "npm:kmenu@^2.0.3", "iso-base": "npm:iso-base@^4.3.0", "lit-html": "npm:lit-html@^3.3.2", "marked": "npm:marked@^17.0.4", diff --git a/src/_data/facets.json b/src/_data/facets.json index 09e1b20d..85089f87 100644 --- a/src/_data/facets.json +++ b/src/_data/facets.json @@ -141,6 +141,13 @@ "category": "Misc", "desc": "Connect to Last.fm to setup the Last.fm scrobbler." }, + { + "url": "facets/misc/command/index.html", + "title": "Command Menu", + "category": "Misc", + "featured": true, + "desc": "A command palette for common actions: add the now-playing track to Favourites, select or create playlists, remove playlists, and toggle repeat/shuffle." + }, { "url": "facets/misc/split-view/index.html", "title": "Split View", diff --git a/src/_includes/layouts/diffuse.vto b/src/_includes/layouts/diffuse.vto index a8d2d22b..9717f886 100644 --- a/src/_includes/layouts/diffuse.vto +++ b/src/_includes/layouts/diffuse.vto @@ -51,6 +51,7 @@ base: "./" "@atcute/cbor": "./vendor/@atcute/cbor/index.js", "@atcute/tid": "./vendor/@atcute/tid/index.js", "idb-keyval": "./vendor/idb-keyval/index.js", + "kmenu": "./vendor/kmenu-core/index.js", "lit-html": "./vendor/lit-html/index.js", "throttle-debounce": "./vendor/throttle-debounce/index.js" } diff --git a/src/facets/misc/command/index.html b/src/facets/misc/command/index.html new file mode 100644 index 00000000..0d025ce0 --- /dev/null +++ b/src/facets/misc/command/index.html @@ -0,0 +1,163 @@ +
+
+ + + +
+
+ + + + diff --git a/src/facets/misc/command/index.inline.js b/src/facets/misc/command/index.inline.js new file mode 100644 index 00000000..d7f860b5 --- /dev/null +++ b/src/facets/misc/command/index.inline.js @@ -0,0 +1,376 @@ +import 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 events +searchInput.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 DOM +menu.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 visible +menu.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} */ (/** @type {unknown} */ (track.tags?.artist ?? "")), + transformations: ["toLowerCase"], + }, + { + field: "tags.title", + value: /** @type {Record} */ (/** @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(); diff --git a/src/vendor/kmenu-core/index.js b/src/vendor/kmenu-core/index.js new file mode 100644 index 00000000..4e8302a8 --- /dev/null +++ b/src/vendor/kmenu-core/index.js @@ -0,0 +1 @@ +export * from "kmenu";