forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
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();