a simple web player for subsonic
tinysub.devins.page
subsonic
navidrome
javascript
1// most keyboard and input control
2// TODO: kinda a monolith file, maybe should split into separate modules later? idk :p
3
4// tab order
5
6// selector for all interactive elements that should be non-tabbable
7const INTERACTIVE_SELECTOR =
8 "button, a, input, select, textarea, [role='button'], tr, li, ul, .section-toggle";
9
10// apply `tabIndex=-1` to ALL interactive elements, only queue and library get tabIndex=0
11function lockTabOrder() {
12 document.querySelectorAll(INTERACTIVE_SELECTOR).forEach((el) => {
13 // skip queue and library which are always tabbable
14 if (el.id === "queue" || el.id === "library") return;
15
16 // skip if element is inside a modal
17 const modal = el.closest(".modal:not(.hidden)");
18 if (modal) return;
19
20 // lock all other interactive elements
21 el.tabIndex = -1;
22 });
23}
24
25// watch for dynamically added elements and lock their tab order
26function setupTabOrderObserver() {
27 let isUpdating = false;
28
29 const observer = new MutationObserver((mutations) => {
30 const hasAddedNodes = mutations.some(
31 (m) => m.type === "childList" && m.addedNodes.length,
32 );
33 if (hasAddedNodes && !isUpdating) lockTabOrder();
34 });
35
36 observer.observe(document.body, {
37 childList: true,
38 subtree: true,
39 });
40
41 // expose flag so queue operations can pause observer during DOM updates
42 return {
43 pauseUpdates: () => {
44 isUpdating = true;
45 },
46 resumeUpdates: () => {
47 isUpdating = false;
48 },
49 };
50}
51
52let tabOrderObserver;
53
54// queue navigation
55
56// navigate queue selection with arrow keys
57const navigateSelection = (offset, extend = false) => {
58 if (!selectionManager) return;
59 const currentIdx = selectionManager.lastSelected ?? 0;
60 const nextIdx =
61 offset < 0
62 ? Math.max(0, currentIdx - 1)
63 : Math.min(state.queue.length - 1, currentIdx + 1);
64
65 if (extend) {
66 selectionManager.select(nextIdx, { shift: true });
67 } else {
68 selectionManager.select(nextIdx);
69 }
70
71 // ensure queue container has focus for keyboard navigation
72 ui.queueList.focus();
73};
74
75// cache element references at module scope to avoid repeated lookups
76const elementCache = { queue: null, library: null };
77
78// get cached element by id, refresh if detached from DOM
79function getCachedElement(type) {
80 const id = type === "queue" ? "queue" : "library";
81 if (!elementCache[type] || !document.body.contains(elementCache[type])) {
82 elementCache[type] = document.getElementById(id);
83 }
84 return elementCache[type];
85}
86
87function getMainEl() {
88 return getCachedElement("queue");
89}
90
91function getLibraryEl() {
92 return getCachedElement("library");
93}
94
95// refocus the container after an action to keep keyboard shortcuts working
96function refocusContext(isInMain, isInSidebar) {
97 if (isInMain) {
98 const mainEl = getMainEl();
99 if (mainEl && document.activeElement !== mainEl) {
100 mainEl.focus();
101 }
102 } else if (isInSidebar) {
103 const libraryEl = getLibraryEl();
104 if (libraryEl && document.activeElement !== libraryEl) {
105 libraryEl.focus();
106 }
107 }
108}
109
110// action handlers for keyboard shortcuts and selection manager
111const keyboardActionHandlers = {
112 play: (selectedIndices) => {
113 if (!selectedIndices || selectedIndices.length === 0) return;
114 playQueueTrack(selectedIndices[0]);
115 updateQueue();
116 refocusContext(true, false);
117 },
118 moveUp: (selectedIndices) => {
119 const firstIdx = Math.min(...selectedIndices);
120 if (firstIdx > 0) {
121 moveQueueItems(
122 state.queue,
123 selectedIndices,
124 firstIdx - 1,
125 queueCallbacks,
126 );
127 }
128 refocusContext(true, false);
129 },
130 moveDown: (selectedIndices) => {
131 const lastIdx = Math.max(...selectedIndices);
132 if (lastIdx < state.queue.length - 1) {
133 moveQueueItems(state.queue, selectedIndices, lastIdx + 2, queueCallbacks);
134 }
135 refocusContext(true, false);
136 },
137 showContextMenu: (selectedIndices) => {
138 // show context menu at the last selected row's position
139 const lastIdx = selectedIndices[selectedIndices.length - 1];
140 const row = ui.queueList.querySelector(
141 `tr[${DATA_ATTRS.INDEX}="${lastIdx}"]`,
142 );
143 if (row) {
144 const rect = row.getBoundingClientRect();
145 showQueueContextMenuAtSelection(
146 rect.left,
147 rect.top + rect.height,
148 selectedIndices,
149 );
150 }
151 refocusContext(true, false);
152 },
153};
154
155// keyboard shortcuts
156
157// setup keyboard shortcuts
158function setupKeyboardShortcuts() {
159 // setup keyboard help close button
160 const closeKeyboardHelpBtn = document.getElementById(
161 "close-keyboard-help-btn",
162 );
163 if (closeKeyboardHelpBtn) {
164 closeKeyboardHelpBtn.onclick = () => hideModal("keyboard-help-modal");
165 }
166
167 document.addEventListener("keydown", (e) => {
168 // skip if user is typing in an input field
169 if (document.activeElement.matches("input, textarea")) return;
170
171 // skip keyboard shortcuts if a modal is open (let modal handle it)
172 if (isModalOpen()) return;
173
174 // cache element lookups for this event to avoid repeated DOM queries
175 const mainEl = getMainEl();
176 const libraryEl = getLibraryEl();
177 const isInMain = mainEl && mainEl.contains(document.activeElement);
178 const isInSidebar = libraryEl && libraryEl.contains(document.activeElement);
179
180 if (!isInMain && !isInSidebar) return;
181
182 switch (e.code) {
183 case "Space": {
184 e.preventDefault();
185 togglePlayback();
186 break;
187 }
188
189 case "Delete":
190 case "Backspace": {
191 if (!isInMain) return; // queue only
192 e.preventDefault();
193 if (selectionManager?.count() > 0) {
194 const selected = selectionManager.getSelected();
195 const nextIdx = Math.min(
196 selected[0],
197 state.queue.length - selected.length - 1,
198 );
199 clearSelectedRows();
200 if (nextIdx >= 0 && state.queue.length > 0) {
201 selectionManager.select(nextIdx);
202 }
203 }
204 refocusContext(true, false);
205 break;
206 }
207
208 case "ArrowUp": {
209 e.preventDefault();
210 if (isInSidebar) {
211 LibraryNavigator.navigate(-1);
212 } else if (e.altKey) {
213 selectionManager?.executeAction("moveUp", keyboardActionHandlers);
214 } else {
215 navigateSelection(-1, e.shiftKey);
216 }
217 refocusContext(isInMain, isInSidebar);
218 break;
219 }
220
221 case "ArrowDown": {
222 e.preventDefault();
223 if (isInSidebar) {
224 LibraryNavigator.navigate(1);
225 } else if (e.altKey) {
226 selectionManager?.executeAction("moveDown", keyboardActionHandlers);
227 } else {
228 navigateSelection(1, e.shiftKey);
229 }
230 refocusContext(isInMain, isInSidebar);
231 break;
232 }
233
234 case "Home": {
235 e.preventDefault();
236 if (isInSidebar) {
237 LibraryNavigator.navigateFirst();
238 } else {
239 if (e.shiftKey) {
240 selectionManager?.select(0, { shift: true });
241 } else {
242 selectionManager?.navigateFirst();
243 }
244 }
245 refocusContext(isInMain, isInSidebar);
246 break;
247 }
248
249 case "End": {
250 e.preventDefault();
251 if (isInSidebar) {
252 LibraryNavigator.navigateLast();
253 } else {
254 const lastIdx = (state?.queue?.length || 0) - 1;
255 if (e.shiftKey) {
256 selectionManager?.select(lastIdx, { shift: true });
257 } else {
258 selectionManager?.navigateLast();
259 }
260 }
261 refocusContext(isInMain, isInSidebar);
262 break;
263 }
264
265 case "PageUp": {
266 e.preventDefault();
267 if (isInMain) {
268 const currentIdx = selectionManager.lastSelected ?? 0;
269 const nextIdx = Math.max(0, currentIdx - 10);
270 if (e.shiftKey) {
271 selectionManager?.select(nextIdx, { shift: true });
272 } else {
273 selectionManager?.navigatePageUp(10);
274 }
275 } else if (isInSidebar) {
276 LibraryNavigator.navigatePageUp(10);
277 }
278 refocusContext(isInMain, isInSidebar);
279 break;
280 }
281
282 case "PageDown": {
283 e.preventDefault();
284 if (isInMain) {
285 const currentIdx = selectionManager.lastSelected ?? 0;
286 const lastIdx = (state?.queue?.length || 0) - 1;
287 const nextIdx = Math.min(lastIdx, currentIdx + 10);
288 if (e.shiftKey) {
289 selectionManager?.select(nextIdx, { shift: true });
290 } else {
291 selectionManager?.navigatePageDown(10);
292 }
293 } else if (isInSidebar) {
294 LibraryNavigator.navigatePageDown(10);
295 }
296 refocusContext(isInMain, isInSidebar);
297 break;
298 }
299
300 case "KeyA": {
301 if (!isInMain) return; // queue only
302 if (!e.ctrlKey && !e.metaKey) return; // Ctrl+A (or Cmd+A on Mac)
303 e.preventDefault();
304 if (state.queue.length > 0) {
305 selectionManager?.setSelection(
306 Array.from({ length: state.queue.length }, (_, i) => i),
307 );
308 }
309 refocusContext(true, false);
310 break;
311 }
312
313 case "Enter": {
314 e.preventDefault();
315 if (isInSidebar) {
316 LibraryNavigator.toggleCurrent();
317 refocusContext(false, true);
318 } else {
319 selectionManager?.executeAction("play", keyboardActionHandlers);
320 }
321 break;
322 }
323
324 case "Escape": {
325 e.preventDefault();
326 cleanupContextMenu();
327 // clear selection only in focused container
328 if (isInMain) {
329 selectionManager.clear();
330 } else if (isInSidebar) {
331 if (LibraryNavigator.currentFocusedItem) {
332 LibraryNavigator.currentFocusedItem.classList.remove(
333 "library-focused",
334 );
335 LibraryNavigator.currentFocusedItem = null;
336 }
337 }
338 refocusContext(isInMain, isInSidebar);
339 break;
340 }
341
342 case "KeyP": {
343 if (!isInSidebar) return; // library only
344 if (e.altKey) {
345 // Alt+P for add next in library
346 e.preventDefault();
347 addLibraryItem(true);
348 updateQueue();
349 } else {
350 // P for add to queue
351 e.preventDefault();
352 addLibraryItem(false);
353 updateQueue();
354 }
355 refocusContext(false, true);
356 break;
357 }
358
359 case "Comma": {
360 if (!(e.ctrlKey || e.metaKey)) return; // Ctrl+, for settings
361 e.preventDefault();
362 const settingsModal = document.getElementById("settings-modal");
363 if (settingsModal) {
364 showModal(settingsModal, {
365 focusSelector: "input, button",
366 closeOnClickOutside: true,
367 });
368 }
369 break;
370 }
371
372 case "ContextMenu": {
373 if (!isInMain) return; // queue only
374 e.preventDefault();
375 selectionManager?.executeAction(
376 "showContextMenu",
377 keyboardActionHandlers,
378 );
379 break;
380 }
381
382 case "Slash": {
383 if (!e.shiftKey) return; // Shift+/ for keyboard help (?)
384 e.preventDefault();
385 const helpModalEl = document.getElementById("keyboard-help-modal");
386 if (!helpModalEl) break;
387
388 if (helpModalEl.classList.contains("hidden")) {
389 showModal(helpModalEl, {
390 focusSelector: "button, input",
391 closeOnClickOutside: true,
392 });
393 } else {
394 hideModal("keyboard-help-modal");
395 }
396 break;
397 }
398
399 case "KeyR": {
400 if (!e.shiftKey) return; // Shift+R for toggle loop (was Ctrl+R)
401 e.preventDefault();
402 state.loop = !state.loop;
403 ui.loopBtn.classList.toggle("active", state.loop);
404 break;
405 }
406
407 case "KeyJ": {
408 if (!isInMain) return; // queue only
409 e.preventDefault();
410 if (e.altKey) {
411 // Alt+J for previous track
412 navigateTrack(-1);
413 } else {
414 // J for seek -10s
415 ui.player.currentTime = Math.max(0, ui.player.currentTime - 10);
416 }
417 break;
418 }
419
420 case "KeyL": {
421 if (!isInMain) return; // queue only
422 e.preventDefault();
423 if (e.altKey) {
424 // Alt+L for next track
425 navigateTrack(1);
426 } else {
427 // L for seek +10s
428 ui.player.currentTime = Math.min(
429 ui.player.duration,
430 ui.player.currentTime + 10,
431 );
432 }
433 break;
434 }
435 }
436 });
437}