a simple web player for subsonic tinysub.devins.page
subsonic navidrome javascript
at main 437 lines 11 kB view raw
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}