A music player that connects to your cloud/distributed storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add command menu

+549
+1
deno.jsonc
··· 42 42 "fast-average-color": "npm:fast-average-color@^9.5.0", 43 43 "fast-uri": "npm:fast-uri@^3.1.0", 44 44 "idb-keyval": "npm:idb-keyval@^6.2.2", 45 + "kmenu": "npm:kmenu@^2.0.3", 45 46 "iso-base": "npm:iso-base@^4.3.0", 46 47 "lit-html": "npm:lit-html@^3.3.2", 47 48 "marked": "npm:marked@^17.0.4",
+7
src/_data/facets.json
··· 142 142 "desc": "Connect to Last.fm to setup the Last.fm scrobbler." 143 143 }, 144 144 { 145 + "url": "facets/misc/command/index.html", 146 + "title": "Command Menu", 147 + "category": "Misc", 148 + "featured": true, 149 + "desc": "A command palette for common actions: add the now-playing track to Favourites, select or create playlists, remove playlists, and toggle repeat/shuffle." 150 + }, 151 + { 145 152 "url": "facets/misc/split-view/index.html", 146 153 "title": "Split View", 147 154 "category": "Misc",
+1
src/_includes/layouts/diffuse.vto
··· 51 51 "@atcute/cbor": "./vendor/@atcute/cbor/index.js", 52 52 "@atcute/tid": "./vendor/@atcute/tid/index.js", 53 53 "idb-keyval": "./vendor/idb-keyval/index.js", 54 + "kmenu": "./vendor/kmenu-core/index.js", 54 55 "lit-html": "./vendor/lit-html/index.js", 55 56 "throttle-debounce": "./vendor/throttle-debounce/index.js" 56 57 }
+163
src/facets/misc/command/index.html
··· 1 + <main> 2 + <div id="command-menu" class="panel"> 3 + <div id="breadcrumbs" aria-label="Navigation path" role="navigation"></div> 4 + <label for="search" id="search-wrapper"> 5 + <input 6 + id="search" 7 + type="text" 8 + placeholder="Type a command…" 9 + autocomplete="off" 10 + spellcheck="false" 11 + role="combobox" 12 + aria-autocomplete="list" 13 + aria-controls="kmenu-listbox" 14 + aria-expanded="true" 15 + /> 16 + </label> 17 + <ul id="kmenu-listbox" role="listbox" aria-label="Commands"></ul> 18 + </div> 19 + </main> 20 + 21 + <style> 22 + @import "./styles/base.css"; 23 + @import "./styles/wireframe/ui.css"; 24 + @import "./vendor/@phosphor-icons/web/bold/style.css"; 25 + 26 + body { 27 + display: flex; 28 + align-items: center; 29 + justify-content: center; 30 + height: 100dvh; 31 + } 32 + 33 + main { 34 + width: min(var(--container-lg), 90vw); 35 + } 36 + 37 + #command-menu { 38 + padding: 0; 39 + overflow: hidden; 40 + } 41 + 42 + #breadcrumbs { 43 + display: flex; 44 + align-items: center; 45 + gap: var(--space-sm); 46 + padding: var(--space-xs) var(--space-sm); 47 + font-size: var(--fs-xs); 48 + font-weight: 600; 49 + text-transform: uppercase; 50 + letter-spacing: var(--tracking-wider); 51 + color: oklch(from var(--text-color) l c h / 0.5); 52 + border-bottom: 2px solid var(--form-color); 53 + } 54 + 55 + #breadcrumbs:empty { 56 + display: none; 57 + } 58 + 59 + .crumb-home { 60 + background: oklch(from var(--text-color) l c h / 0.075); 61 + border: none; 62 + border-radius: var(--radius-sm); 63 + cursor: pointer; 64 + padding: 0; 65 + font-size: inherit; 66 + font-weight: inherit; 67 + line-height: 0.75; 68 + padding: var(--space-2xs); 69 + letter-spacing: inherit; 70 + text-box: trim-both cap alphabetic; 71 + text-transform: inherit; 72 + 73 + &:hover { 74 + text-decoration: underline; 75 + } 76 + } 77 + 78 + #search-wrapper { 79 + display: flex; 80 + align-items: center; 81 + gap: var(--space-xs); 82 + padding: var(--space-sm); 83 + border-bottom: 2px solid var(--form-color); 84 + } 85 + 86 + #search-wrapper i { 87 + flex-shrink: 0; 88 + color: oklch(from var(--text-color) l c h / 0.5); 89 + pointer-events: none; 90 + } 91 + 92 + #search { 93 + border: none; 94 + padding: 0; 95 + font-size: var(--fs-sm); 96 + background: transparent; 97 + flex: 1; 98 + width: 100%; 99 + } 100 + 101 + #kmenu-listbox { 102 + list-style: none; 103 + margin: 0; 104 + padding: var(--space-2xs) 0; 105 + max-height: 340px; 106 + overflow-y: auto; 107 + } 108 + 109 + .group-label { 110 + color: oklch(from var(--text-color) l c h / 0.45); 111 + font-size: var(--fs-2xs); 112 + font-weight: 600; 113 + letter-spacing: calc(var(--tracking-widest) * 2); 114 + padding: var(--space-2xs) var(--space-sm); 115 + pointer-events: none; 116 + text-transform: uppercase; 117 + } 118 + 119 + .option { 120 + display: flex; 121 + align-items: center; 122 + gap: var(--space-xs); 123 + padding: var(--space-xs) var(--space-sm); 124 + cursor: pointer; 125 + user-select: none; 126 + font-size: var(--fs-sm); 127 + } 128 + 129 + .option[aria-selected="true"] { 130 + background-color: var(--accent); 131 + color: var(--bg-color); 132 + } 133 + 134 + .option[aria-disabled="true"] { 135 + opacity: 0.35; 136 + cursor: not-allowed; 137 + pointer-events: none; 138 + } 139 + 140 + .option-label { 141 + flex: 1; 142 + } 143 + 144 + .option .option-chevron { 145 + flex-shrink: 0; 146 + font-size: var(--fs-xs); 147 + opacity: 0.6; 148 + } 149 + 150 + .panel { 151 + gap: 0; 152 + } 153 + 154 + .empty-message { 155 + padding: var(--space-md); 156 + text-align: center; 157 + color: oklch(from var(--text-color) l c h / 0.45); 158 + font-size: var(--fs-sm); 159 + pointer-events: none; 160 + } 161 + </style> 162 + 163 + <script type="module" src="facets/misc/command/index.inline.js"></script>
+376
src/facets/misc/command/index.inline.js
··· 1 + import foundation from "~/common/foundation.js"; 2 + import { effect } from "~/common/signal.js"; 3 + import { CommandCore } from "~/vendor/kmenu-core/index.js"; 4 + import * as Playlist from "~/common/playlist.js"; 5 + 6 + foundation.setup({ title: "Command Menu | Diffuse" }); 7 + 8 + // --------------------------------------------------------------------------- 9 + // Foundation setup 10 + // --------------------------------------------------------------------------- 11 + 12 + const [queue, repeatShuffle, output, scope, favourites] = await Promise.all([ 13 + foundation.engine.queue(), 14 + foundation.engine.repeatShuffle(), 15 + foundation.orchestrator.output(), 16 + foundation.engine.scope(), 17 + foundation.orchestrator.favourites(), 18 + ]); 19 + 20 + // --------------------------------------------------------------------------- 21 + // DOM elements 22 + // --------------------------------------------------------------------------- 23 + 24 + const searchInput = 25 + /** @type {HTMLInputElement} */ (document.querySelector("#search")); 26 + const optionsList = 27 + /** @type {HTMLUListElement} */ (document.querySelector("#kmenu-listbox")); 28 + const breadcrumbsEl = 29 + /** @type {HTMLElement} */ (document.querySelector("#breadcrumbs")); 30 + 31 + // --------------------------------------------------------------------------- 32 + // Command menu instance 33 + // --------------------------------------------------------------------------- 34 + 35 + const menu = new CommandCore({ 36 + // At root level, the package searches allOptions (full flattened tree) which 37 + // includes submenu children. Restrict to root-only items by filtering out 38 + // anything with a parent (i.e. options that belong to a submenu). 39 + filter: (options, query) => { 40 + if (!query) return options; 41 + const rootOnly = options.filter((/** @type {any} */ o) => !o.parent); 42 + const q = query.toLowerCase(); 43 + return rootOnly.filter( 44 + (/** @type {any} */ o) => 45 + !o.disabled && 46 + (o.label.toLowerCase().includes(q) || 47 + o.keywords?.some((/** @type {string} */ k) => 48 + k.toLowerCase().includes(q) 49 + )), 50 + ); 51 + }, 52 + }); 53 + 54 + // Register DOM elements with CommandCore (for scroll management and focus) 55 + menu.getListboxProps().ref(optionsList); 56 + menu.getInputProps().ref(searchInput); 57 + 58 + // Wire up input events 59 + searchInput.oninput = (/** @type {Event} */ e) => 60 + menu.setInput(/** @type {HTMLInputElement} */ (e.target).value); 61 + searchInput.onkeydown = (/** @type {KeyboardEvent} */ e) => 62 + menu.getInputProps().onKeyDown(e); 63 + 64 + // The package's Escape handler always calls close(), never goBack(). Intercept 65 + // at the window level so it works regardless of which element has focus. 66 + window.addEventListener("keydown", (/** @type {KeyboardEvent} */ e) => { 67 + if (e.key === "Escape" && menu.getState().breadcrumbs.length > 0) { 68 + e.preventDefault(); 69 + e.stopImmediatePropagation(); 70 + menu.goBack(); 71 + } 72 + }); 73 + 74 + // --------------------------------------------------------------------------- 75 + // Render 76 + // --------------------------------------------------------------------------- 77 + 78 + function render() { 79 + const state = menu.getState(); 80 + 81 + // Sync input value without disturbing cursor position when focused 82 + if (document.activeElement !== searchInput) { 83 + searchInput.value = state.input; 84 + } 85 + 86 + // Breadcrumbs 87 + breadcrumbsEl.innerHTML = ""; 88 + 89 + if (state.breadcrumbs.length > 0) { 90 + const home = document.createElement("button"); 91 + home.className = "crumb-home"; 92 + home.textContent = "Home"; 93 + home.onclick = () => { 94 + while (menu.getState().breadcrumbs.length > 0) menu.goBack(); 95 + }; 96 + breadcrumbsEl.appendChild(home); 97 + 98 + for (const crumb of state.breadcrumbs) { 99 + const span = document.createElement("span"); 100 + span.textContent = crumb.label; 101 + breadcrumbsEl.appendChild(span); 102 + } 103 + } 104 + 105 + // Options list 106 + optionsList.innerHTML = ""; 107 + 108 + if (state.filtered.length === 0) { 109 + const li = document.createElement("li"); 110 + li.className = "empty-message"; 111 + li.textContent = "No commands found"; 112 + optionsList.appendChild(li); 113 + return; 114 + } 115 + 116 + let currentGroup = null; 117 + 118 + for (const option of state.filtered) { 119 + // Group header 120 + if (option.group && option.group !== currentGroup) { 121 + currentGroup = option.group; 122 + const groupLi = document.createElement("li"); 123 + groupLi.className = "group-label"; 124 + groupLi.setAttribute("role", "presentation"); 125 + groupLi.textContent = option.group; 126 + optionsList.appendChild(groupLi); 127 + } 128 + 129 + const li = document.createElement("li"); 130 + li.className = "option"; 131 + 132 + const props = menu.getOptionProps(/** @type {string} */ (option.id)); 133 + props.ref(li); // Register with CommandCore for scroll management 134 + 135 + li.setAttribute("id", `kmenu-option-${option.id}`); 136 + li.setAttribute("role", "option"); 137 + li.setAttribute( 138 + "aria-selected", 139 + state.activeId === option.id ? "true" : "false", 140 + ); 141 + if (option.disabled) li.setAttribute("aria-disabled", "true"); 142 + 143 + const label = document.createElement("span"); 144 + label.className = "option-label"; 145 + label.textContent = option.label; 146 + li.appendChild(label); 147 + 148 + const hasChildren = option.children && option.children.length > 0; 149 + if (hasChildren) { 150 + const chevron = document.createElement("i"); 151 + chevron.className = "ph-bold ph-caret-right option-chevron"; 152 + chevron.setAttribute("aria-hidden", "true"); 153 + li.appendChild(chevron); 154 + } 155 + 156 + li.onclick = props.onClick; 157 + li.onmouseenter = props.onMouseEnter; 158 + 159 + optionsList.appendChild(li); 160 + } 161 + } 162 + 163 + // Full re-render only for structural changes (list content changes) 164 + for (const event of ["open", "change", "submenu", "back"]) { 165 + menu.on(/** @type {any} */ (event), render); 166 + } 167 + 168 + // Navigation: update only the active highlight without rebuilding the DOM 169 + menu.on("navigate", (/** @type {any} */ e) => { 170 + for (const li of optionsList.querySelectorAll("[role=option]")) { 171 + li.setAttribute( 172 + "aria-selected", 173 + li.id === `kmenu-option-${e.activeId}` ? "true" : "false", 174 + ); 175 + } 176 + if (e.activeId) { 177 + searchInput.setAttribute( 178 + "aria-activedescendant", 179 + `kmenu-option-${e.activeId}`, 180 + ); 181 + } else { 182 + searchInput.removeAttribute("aria-activedescendant"); 183 + } 184 + }); 185 + 186 + // Re-open the menu immediately on close to keep it always visible 187 + menu.on("close", () => menu.open()); 188 + 189 + // --------------------------------------------------------------------------- 190 + // Commands — registered reactively via effect() 191 + // --------------------------------------------------------------------------- 192 + 193 + effect(() => { 194 + const nowItem = queue.now(); 195 + const isRepeat = repeatShuffle.repeat(); 196 + const isShuffle = repeatShuffle.shuffle(); 197 + const currentPlaylist = scope.playlist(); 198 + 199 + const tracksCol = output.tracks.collection(); 200 + const now = nowItem && tracksCol.state === "loaded" 201 + ? tracksCol.data.find((t) => t.id === nowItem.id) ?? null 202 + : null; 203 + 204 + const col = output.playlistItems.collection(); 205 + const items = col.state === "loaded" ? col.data : []; 206 + 207 + const playlistMap = Playlist.gather(items); 208 + const playlists = [...playlistMap.values()].sort((a, b) => 209 + a.name.localeCompare(b.name) 210 + ); 211 + 212 + const isFav = now && favourites ? favourites.isFavourite(now) : false; 213 + const nowLabel = now 214 + ? (now.tags?.artist 215 + ? `${now.tags.artist} - ${now.tags.title}` 216 + : "current track") 217 + : null; 218 + 219 + menu.registerOptions([ 220 + // ------------------------------------------------------------------ 221 + // Playback 222 + // ------------------------------------------------------------------ 223 + { 224 + id: "favourite-toggle", 225 + label: nowLabel 226 + ? isFav 227 + ? `Remove "${nowLabel}" from favourites` 228 + : `Add "${nowLabel}" to favourites` 229 + : "Add now playing to favourites", 230 + keywords: ["favourite", "favorite", "like", "heart", "star"], 231 + group: "Playback", 232 + disabled: !now, 233 + action: () => { 234 + const item = queue.now(); 235 + if (!item) return; 236 + const tc = output.tracks.collection(); 237 + const track = tc.state === "loaded" 238 + ? tc.data.find((t) => t.id === item.id) 239 + : undefined; 240 + if (track) favourites.toggle(track); 241 + }, 242 + }, 243 + { 244 + id: "toggle-repeat", 245 + label: isRepeat ? "Disable repeat" : "Enable repeat", 246 + keywords: ["repeat", "loop"], 247 + group: "Playback", 248 + action: () => { 249 + repeatShuffle.setRepeat(!repeatShuffle.repeat()); 250 + }, 251 + }, 252 + { 253 + id: "toggle-shuffle", 254 + label: isShuffle ? "Disable shuffle" : "Enable shuffle", 255 + keywords: ["shuffle", "random"], 256 + group: "Playback", 257 + action: () => { 258 + repeatShuffle.setShuffle(!repeatShuffle.shuffle()); 259 + }, 260 + }, 261 + 262 + // ------------------------------------------------------------------ 263 + // Playlists 264 + // ------------------------------------------------------------------ 265 + { 266 + id: "select-playlist", 267 + label: currentPlaylist 268 + ? `Playlist: ${currentPlaylist}` 269 + : "Select playlist", 270 + keywords: ["playlist", "filter", "browse", "queue"], 271 + group: "Playlists", 272 + children: [ 273 + { 274 + id: "playlist-all", 275 + label: "All tracks", 276 + keywords: ["all", "everything", "reset"], 277 + action: () => { 278 + scope.setPlaylist(undefined); 279 + }, 280 + }, 281 + ...playlists.map((p) => ({ 282 + id: `playlist-select-${p.name}`, 283 + label: p.name, 284 + action: () => { 285 + scope.setPlaylist(p.name); 286 + }, 287 + })), 288 + ], 289 + }, 290 + { 291 + id: "create-playlist", 292 + label: nowLabel 293 + ? `Create playlist with "${nowLabel}"` 294 + : "Create playlist", 295 + keywords: ["new", "add", "create", "playlist"], 296 + group: "Playlists", 297 + disabled: !now, 298 + action: () => { 299 + const item = queue.now(); 300 + if (!item) return; 301 + const tc = output.tracks.collection(); 302 + const track = tc.state === "loaded" 303 + ? tc.data.find((t) => t.id === item.id) 304 + : undefined; 305 + if (!track) return; 306 + 307 + const name = prompt("New playlist name:"); 308 + if (!name?.trim()) return; 309 + 310 + const col = output.playlistItems.collection(); 311 + const existing = col.state === "loaded" ? col.data : []; 312 + const ts = new Date().toISOString(); 313 + 314 + output.playlistItems.save([ 315 + ...existing, 316 + { 317 + $type: "sh.diffuse.output.playlistItem", 318 + id: crypto.randomUUID(), 319 + playlist: name.trim(), 320 + criteria: [ 321 + { 322 + field: "tags.artist", 323 + value: /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (track.tags?.artist ?? "")), 324 + transformations: ["toLowerCase"], 325 + }, 326 + { 327 + field: "tags.title", 328 + value: /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (track.tags?.title ?? "")), 329 + transformations: ["toLowerCase"], 330 + }, 331 + ], 332 + createdAt: ts, 333 + updatedAt: ts, 334 + }, 335 + ]); 336 + }, 337 + }, 338 + { 339 + id: "remove-playlist", 340 + label: "Remove playlist", 341 + keywords: ["delete", "remove", "playlist"], 342 + group: "Playlists", 343 + disabled: playlists.length === 0, 344 + children: playlists.map((p) => ({ 345 + id: `playlist-remove-${p.name}`, 346 + label: p.name, 347 + children: [ 348 + { 349 + id: `playlist-remove-${p.name}-confirm`, 350 + label: `Confirm: remove "${p.name}"`, 351 + keywords: ["yes", "confirm", "delete"], 352 + action: () => { 353 + const col = output.playlistItems.collection(); 354 + const existing = col.state === "loaded" ? col.data : []; 355 + output.playlistItems.save( 356 + existing.filter((item) => item.playlist !== p.name), 357 + ); 358 + }, 359 + }, 360 + ], 361 + })), 362 + }, 363 + ]); 364 + 365 + // Re-render after options change so labels (repeat/shuffle/now-playing) 366 + // stay in sync without relying on the navigate event. 367 + render(); 368 + }); 369 + 370 + // --------------------------------------------------------------------------- 371 + // Open and signal ready 372 + // --------------------------------------------------------------------------- 373 + 374 + menu.open(); 375 + 376 + foundation.ready();
+1
src/vendor/kmenu-core/index.js
··· 1 + export * from "kmenu";