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.

at v4 376 lines 12 kB view raw
1import foundation from "~/common/foundation.js"; 2import { effect } from "~/common/signal.js"; 3import { CommandCore } from "~/vendor/kmenu-core/index.js"; 4import * as Playlist from "~/common/playlist.js"; 5 6foundation.setup({ title: "Command Menu | Diffuse" }); 7 8// --------------------------------------------------------------------------- 9// Foundation setup 10// --------------------------------------------------------------------------- 11 12const [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 24const searchInput = 25 /** @type {HTMLInputElement} */ (document.querySelector("#search")); 26const optionsList = 27 /** @type {HTMLUListElement} */ (document.querySelector("#kmenu-listbox")); 28const breadcrumbsEl = 29 /** @type {HTMLElement} */ (document.querySelector("#breadcrumbs")); 30 31// --------------------------------------------------------------------------- 32// Command menu instance 33// --------------------------------------------------------------------------- 34 35const 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) 55menu.getListboxProps().ref(optionsList); 56menu.getInputProps().ref(searchInput); 57 58// Wire up input events 59searchInput.oninput = (/** @type {Event} */ e) => 60 menu.setInput(/** @type {HTMLInputElement} */ (e.target).value); 61searchInput.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. 66window.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 78function 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) 164for (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 169menu.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 187menu.on("close", () => menu.open()); 188 189// --------------------------------------------------------------------------- 190// Commands — registered reactively via effect() 191// --------------------------------------------------------------------------- 192 193effect(() => { 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 374menu.open(); 375 376foundation.ready();