a simple web player for subsonic tinysub.devins.page
subsonic navidrome javascript
11
fork

Configure Feed

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

feat+refactor: make app fully keyboard compatible, and other changes

you can now completely operate tinysub with the keyboard, press `?` to view the shortcuts. you can switch between library and main with tab and shift+tab, and navigate with arrow keys

i also rewrote a lot of things in this commit, one thing to note is that dragging is now much more performant even with large queues, and some other little fixes to inconsistencies and perf issues

for whatever reason i thought i had to loop updatemediasession.. apparently that is not the case... fixed that aswell

there's a ton of other stuff fixed in this commit sorry i probably should have separated it but too lazy zzz godnight

intergrav 70b850bd 34bd1bb1

+1420 -293
+81 -16
src/css/components.css
··· 153 153 margin-block-start: 0.25rem; 154 154 } 155 155 156 + #sidebar #library .tree-toggle.library-focused, 157 + #sidebar #library .tree-name.library-focused, 158 + #sidebar #library .section-toggle.library-focused { 159 + background: Highlight; 160 + color: HighlightText; 161 + } 162 + 163 + #sidebar #library:not(:focus-within) .tree-toggle.library-focused, 164 + #sidebar #library:not(:focus-within) .tree-name.library-focused, 165 + #sidebar #library:not(:focus-within) .section-toggle.library-focused { 166 + background: GrayText; 167 + } 168 + 156 169 /* sidebar - now playing */ 157 170 #sidebar #now-playing { 158 171 display: flex; ··· 264 277 color: HighlightText; 265 278 } 266 279 280 + #queue:not(:focus-within) #queue-table tbody tr.selected { 281 + background: GrayText; 282 + } 283 + 267 284 #queue #queue-table tbody tr.drag-over-above { 268 285 border-block-start: 2px solid currentColor; 269 286 } ··· 337 354 color: HighlightText; 338 355 } 339 356 357 + #context-menu .context-menu-item.focused { 358 + background: Highlight; 359 + color: HighlightText; 360 + } 361 + 340 362 /* MODAL */ 341 363 .modal { 342 364 position: fixed; ··· 356 378 background: Menu; 357 379 border: 1px solid var(--border); 358 380 padding: 1rem; 381 + max-height: 100%; 382 + max-width: 24rem; 383 + overflow-y: auto; 359 384 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); 385 + display: flex; 386 + flex-direction: column; 387 + gap: 1rem; 360 388 } 361 389 362 - .modal-content h2 { 363 - margin-bottom: 1rem; 390 + .modal-actions { 391 + display: flex; 392 + gap: 0.5rem; 393 + justify-content: end; 394 + } 395 + 396 + .modal-actions button { 397 + background: ButtonFace; 398 + padding: 0.5rem 1rem; 399 + } 400 + 401 + #keyboard-help-modal .shortcuts-grid { 402 + display: flex; 403 + flex-direction: column; 404 + gap: 1rem; 405 + } 406 + 407 + #keyboard-help-modal .shortcut-section-group { 408 + display: flex; 409 + flex-direction: column; 410 + gap: 0.5rem; 364 411 } 365 412 366 - /* FORMS & SETTINGS */ 367 - .form-group, 368 - .settings-group { 369 - margin-bottom: 1.5rem; 413 + #keyboard-help-modal .shortcuts-items-grid { 414 + display: grid; 415 + grid-template-columns: 10rem 1fr; 416 + gap: 0.5rem 1rem; 370 417 } 371 418 372 - .form-group label, 373 - .settings-group label { 374 - display: block; 375 - margin-bottom: 0.5rem; 419 + #keyboard-help-modal kbd { 420 + background: ButtonFace; 421 + padding: 0.25rem 0.5rem; 422 + font-family: ui-monospace, monospace; 423 + } 424 + 425 + /* form groups */ 426 + .form-group { 427 + display: flex; 428 + flex-direction: column; 429 + gap: 1rem; 430 + } 431 + 432 + .modal-content form { 433 + display: flex; 434 + flex-direction: column; 435 + gap: 1rem; 376 436 } 377 437 378 438 /* UTILITIES */ 379 - /* error */ 380 - .error { 439 + /* danger */ 440 + .danger { 381 441 color: var(--error-color); 442 + } 443 + 444 + button.danger { 445 + background-color: var(--error-color); 446 + color: white; 382 447 } 383 448 384 449 /* famfamfam-silk icons - force pixelated rendering for retina displays */ ··· 400 465 } 401 466 402 467 /* queue - hide album and duration columns */ 403 - th:nth-child(4), 404 - th:nth-child(5), 405 - td:nth-child(4), 406 - td:nth-child(5) { 468 + #queue-table th:nth-child(4), 469 + #queue-table th:nth-child(5), 470 + #queue-table td:nth-child(4), 471 + #queue-table td:nth-child(5) { 407 472 display: none; 408 473 } 409 474
+81 -20
src/index.html
··· 10 10 <link rel="stylesheet" href="css/components.css" /> 11 11 </head> 12 12 <body> 13 - <!-- icons used by tinysub --> 13 + <!-- icons cache --> 14 14 <!-- this serves as a lookup table for the build process, which inlines the imgs into a single html --> 15 15 <div id="icon-cache" style="display: none"> 16 16 <img id="icon-play" src="static/famfamfam-silk/control_play_blue.png" /> ··· 26 26 <img id="icon-remove" src="static/famfamfam-silk/cross.png" /> 27 27 <img id="icon-tinysub" src="static/tinysub.svg" /> 28 28 </div> 29 - <!-- login panel --> 30 - <div id="auth-modal" class="modal"> 29 + <!-- auth modal --> 30 + <div id="auth-modal" class="modal hidden"> 31 31 <div class="modal-content"> 32 32 <h2>tinysub</h2> 33 33 <form id="login-form"> ··· 53 53 required 54 54 /> 55 55 </div> 56 - <button type="submit">connect</button> 57 - <p id="auth-error" class="error"></p> 56 + <p> 57 + <a href="https://tangled.org/devins.page/tinysub">source code</a> 58 + </p> 59 + <p id="auth-error" class="danger"></p> 60 + <div class="modal-actions"> 61 + <button type="submit">connect</button> 62 + </div> 58 63 </form> 59 64 </div> 60 65 </div> 61 - <!-- settings panel --> 66 + <!-- settings modal --> 62 67 <div id="settings-modal" class="modal hidden"> 63 68 <div class="modal-content"> 64 69 <h2>settings</h2> 65 - <div class="settings-group"> 70 + <div class="form-group"> 66 71 <label> 67 72 <input type="checkbox" id="scrobbling-toggle" /> 68 73 enable scrobbling 69 74 </label> 70 75 </div> 71 - <div class="settings-group"> 76 + <div class="form-group"> 72 77 <label 73 78 >artist art size: <span id="artist-size-display">16</span>px</label 74 79 > ··· 81 86 value="16" 82 87 /> 83 88 </div> 84 - <div class="settings-group"> 89 + <div class="form-group"> 85 90 <label 86 91 >album art size: <span id="album-size-display">32</span>px</label 87 92 > ··· 94 99 value="32" 95 100 /> 96 101 </div> 97 - <div class="settings-group"> 102 + <div class="form-group"> 98 103 <label>song art size: <span id="song-size-display">16</span>px</label> 99 104 <input 100 105 type="range" ··· 105 110 value="16" 106 111 /> 107 112 </div> 108 - <div class="settings-group"> 113 + <div class="form-group"> 109 114 <label 110 115 >now playing art size: 111 116 <span id="now-playing-size-display">128</span>px</label ··· 119 124 value="128" 120 125 /> 121 126 </div> 122 - <div class="settings-group"> 123 - <button id="logout-settings-btn">logout</button> 127 + <div class="modal-actions"> 128 + <button id="logout-settings-btn" class="danger">logout</button> 129 + <button id="close-settings-btn">close</button> 130 + </div> 131 + </div> 132 + </div> 133 + <!-- keyboard help modal --> 134 + <div id="keyboard-help-modal" class="modal hidden"> 135 + <div class="modal-content"> 136 + <h2>keyboard help</h2> 137 + <div class="shortcuts-grid"> 138 + <div class="shortcut-section-group"> 139 + <h3>general</h3> 140 + <div class="shortcuts-items-grid"> 141 + <kbd>Ctrl+,</kbd><span>open settings</span> <kbd>?</kbd 142 + ><span>open keyboard help</span> 143 + </div> 144 + </div> 145 + <div class="shortcut-section-group"> 146 + <h3>playback</h3> 147 + <div class="shortcuts-items-grid"> 148 + <kbd>Space</kbd><span>play/pause</span> <kbd>J</kbd 149 + ><span>seek -10s</span> <kbd>L</kbd><span>seek +10s</span> 150 + <kbd>Alt+J</kbd><span>previous track</span> <kbd>Alt+L</kbd 151 + ><span>next track</span> <kbd>Shift+R</kbd 152 + ><span>toggle loop</span> 153 + </div> 154 + </div> 155 + <div class="shortcut-section-group"> 156 + <h3>library</h3> 157 + <div class="shortcuts-items-grid"> 158 + <kbd>↑ / ↓</kbd><span>navigate rows</span> <kbd>Home / End</kbd 159 + ><span>first / last row</span> <kbd>Page Up / Down</kbd 160 + ><span>jump ±10 rows</span> <kbd>Enter</kbd 161 + ><span>expand/collapse</span> <kbd>P</kbd 162 + ><span>add to queue</span> <kbd>Alt+P</kbd><span>play next</span> 163 + <kbd>Esc</kbd><span>clear focus</span> 164 + </div> 165 + </div> 166 + <div class="shortcut-section-group"> 167 + <h3>queue</h3> 168 + <div class="shortcuts-items-grid"> 169 + <kbd>↑ / ↓</kbd><span>navigate rows</span> <kbd>Shift + ↑ / ↓</kbd 170 + ><span>extend selection</span> <kbd>Home / End</kbd 171 + ><span>first / last row</span> <kbd>Page Up / Down</kbd 172 + ><span>jump ±10 rows</span> <kbd>Alt + ↑ / ↓</kbd 173 + ><span>move selected rows</span> <kbd>Enter</kbd 174 + ><span>play selected row</span> <kbd>Delete / Backspace</kbd 175 + ><span>remove selected</span> <kbd>Menu</kbd 176 + ><span>context menu</span> <kbd>Ctrl+A</kbd 177 + ><span>select all</span> <kbd>Esc</kbd 178 + ><span>clear selection</span> 179 + </div> 180 + </div> 124 181 </div> 125 - <button id="close-settings-btn">close</button> 182 + <div class="modal-actions"> 183 + <button id="close-keyboard-help-btn">close</button> 184 + </div> 126 185 </div> 127 186 </div> 187 + 128 188 <header id="playback"> 129 189 <audio id="player" crossorigin="anonymous"></audio> 130 190 <button id="prev-btn" aria-label="previous"> ··· 212 272 </div> 213 273 </footer> 214 274 215 - <script src="js/constants.js"></script> 216 275 <script src="js/strings/en.js"></script> 276 + <script src="js/constants.js"></script> 217 277 <script src="js/validation.js"></script> 218 - <script src="js/selection-manager.js"></script> 278 + <script src="js/selection.js"></script> 219 279 <script src="js/api.js"></script> 220 280 <script src="js/state.js"></script> 221 - <script src="js/settings.js"></script> 222 281 <script src="js/image-cache.js"></script> 223 282 <script src="js/virtual-scroll.js"></script> 224 283 <script src="js/queue.js"></script> 225 284 <script src="js/library.js"></script> 285 + <script src="js/modal.js"></script> 286 + <script src="js/input.js"></script> 287 + <script src="js/contextmenu.js"></script> 226 288 <script src="js/ui.js"></script> 227 - <script src="js/spark-md5.js"></script> 289 + <script src="js/settings.js"></script> 228 290 <script src="js/auth.js"></script> 291 + <script src="js/spark-md5.js"></script> 229 292 <script src="js/player.js"></script> 230 293 <script src="js/draggable.js"></script> 231 - <script src="js/contextmenu.js"></script> 232 294 <script src="js/lyrics.js"></script> 233 - <script src="js/input.js"></script> 234 295 <script src="js/events.js"></script> 235 296 </body> 236 297 </html>
+13 -6
src/js/auth.js
··· 15 15 }, 16 16 }; 17 17 18 - // toggle auth modal visibility 18 + // toggle auth modal 19 19 const toggleAuthModal = (show) => { 20 - document.getElementById(DOM_IDS.AUTH_MODAL).classList.toggle("hidden", !show); 20 + const authModal = document.getElementById(DOM_IDS.AUTH_MODAL); 21 + if (!authModal) return; 22 + 23 + if (show) { 24 + showModal(authModal, { 25 + focusSelector: "input", 26 + }); 27 + } else { 28 + hideModal(DOM_IDS.AUTH_MODAL); 29 + } 21 30 }; 22 31 23 32 // load library, playlists, and favorites after successful login ··· 51 60 } 52 61 53 62 handleLogin.isInProgress = true; 54 - const { 55 - value: { username: validUsername, password: validPassword }, 56 - } = credValidation; 63 + const { username: validUsername, password: validPassword } = 64 + credValidation.value; 57 65 const validServerUrl = urlValidation.value; 58 66 59 67 try { ··· 70 78 handleLogin.isInProgress = false; 71 79 } 72 80 } 73 - handleLogin.isInProgress = false; 74 81 75 82 // clear localStorage and reload to logout 76 83 function handleLogout() {
+2 -3
src/js/constants.js
··· 30 30 31 31 const DOM_IDS = { 32 32 AUTH_MODAL: "auth-modal", 33 + SETTINGS_MODAL: "settings-modal", 33 34 LOGIN_FORM: "login-form", 34 35 CONTEXT_MENU: "context-menu", 35 36 QUEUE_TABLE: "queue-table", ··· 81 82 REMOVE: 82 83 document.getElementById("icon-remove")?.src || 83 84 "static/famfamfam-silk/cross.png", 84 - TINYSUB: 85 - document.getElementById("icon-tinysub")?.src || 86 - "static/tinysub.svg", 85 + TINYSUB: document.getElementById("icon-tinysub")?.src || "static/tinysub.svg", 87 86 };
+243 -82
src/js/contextmenu.js
··· 1 1 // context menu 2 2 3 3 let contextMenuEl = null; 4 - let currentHideMenuHandler = null; 4 + let currentKeyboardHandler = null; 5 + let currentClickHandler = null; 6 + let currentSystemContextmenuEventHandler = null; 7 + 8 + // remove context menu display and event listeners 9 + function removeContextMenuDisplay() { 10 + if (!contextMenuEl) return; 11 + contextMenuEl.remove(); 12 + contextMenuEl = null; 13 + 14 + if (currentKeyboardHandler) { 15 + document.removeEventListener("keydown", currentKeyboardHandler); 16 + currentKeyboardHandler = null; 17 + } 5 18 6 - // remove context menu 7 - function cleanupContextMenu() { 8 - if (contextMenuEl) { 9 - contextMenuEl.remove(); 10 - contextMenuEl = null; 19 + if (currentClickHandler) { 20 + document.removeEventListener("click", currentClickHandler, { 21 + capture: true, 22 + }); 23 + currentClickHandler = null; 11 24 } 12 - if (currentHideMenuHandler) { 13 - ["click", "contextmenu"].forEach((event) => 14 - document.removeEventListener(event, currentHideMenuHandler, { 25 + 26 + if (currentSystemContextmenuEventHandler) { 27 + document.removeEventListener( 28 + "contextmenu", 29 + currentSystemContextmenuEventHandler, 30 + { 15 31 capture: true, 16 - }), 32 + }, 17 33 ); 18 - currentHideMenuHandler = null; 34 + currentSystemContextmenuEventHandler = null; 19 35 } 20 36 } 21 37 38 + // remove context menu and cleanup all listeners 39 + function cleanupContextMenu() { 40 + if (!contextMenuEl) return; 41 + 42 + removeContextMenuDisplay(); 43 + 44 + // restore focus to main immediately 45 + // NOTE: if we ever add context menus to library items, we'll want to make this dynamic but i think this is ok for now 46 + getMainEl().focus(); 47 + } 48 + 22 49 // display context menu with given items at position 23 50 function showContextMenu(x, y, items) { 24 - cleanupContextMenu(); 51 + // close any existing menu 52 + removeContextMenuDisplay(); 25 53 26 54 contextMenuEl = createElement("div", { 27 - attributes: { id: DOM_IDS.CONTEXT_MENU }, 55 + attributes: { id: DOM_IDS.CONTEXT_MENU, role: "menu" }, 28 56 }); 29 57 58 + const menuItems = []; 30 59 Object.entries(items).forEach(([label, handler]) => { 31 60 const item = createElement("button", { 32 61 className: CLASSES.CONTEXT_MENU_ITEM, 33 62 textContent: label, 34 - listeners: { click: handler }, 63 + attributes: { role: "menuitem", tabindex: "-1" }, 64 + listeners: { 65 + mousedown: (e) => e.preventDefault(), // prevent focus on mousedown 66 + click: (e) => { 67 + e.stopPropagation(); 68 + handler(); 69 + }, 70 + }, 35 71 }); 72 + menuItems.push(item); 36 73 contextMenuEl.appendChild(item); 37 74 }); 38 75 39 - document.body.appendChild(contextMenuEl); 76 + // append to main element to keep focus within main 77 + // NOTE: if we ever add context menus to library items, we'll want to make this dynamic but i think this is ok for now 78 + getMainEl().appendChild(contextMenuEl); 40 79 41 - // position menu 80 + // position menu with bounds checking to keep within viewport 42 81 const rect = contextMenuEl.getBoundingClientRect(); 43 - contextMenuEl.style.left = `${Math.max(0, Math.min(x, window.innerWidth - rect.width))}px`; 44 - contextMenuEl.style.top = `${Math.max(0, Math.min(y, window.innerHeight - rect.height))}px`; 82 + const clampPosition = (pos, size, limit) => 83 + Math.max(0, Math.min(pos, limit - size)); 84 + contextMenuEl.style.left = `${clampPosition(x, rect.width, window.innerWidth)}px`; 85 + contextMenuEl.style.top = `${clampPosition(y, rect.height, window.innerHeight)}px`; 45 86 46 - currentHideMenuHandler = cleanupContextMenu; 87 + // keyboard navigation for context menu 88 + let focusedIndex = 0; 47 89 48 - ["click", "contextmenu"].forEach((event) => 49 - document.addEventListener(event, currentHideMenuHandler, { capture: true }), 50 - ); 51 - } 90 + const updateMenuItemFocus = () => { 91 + const focused = contextMenuEl.querySelector(".focused"); 92 + if (focused) focused.classList.remove("focused"); 93 + menuItems[focusedIndex].classList.add("focused"); 94 + }; 52 95 53 - // setup right-click context menu for queue table 54 - function setupQueueContextMenu() { 55 - ui.queueList.addEventListener("contextmenu", (e) => { 56 - const row = getClosestRow(e.target); 57 - if (!row) return; 96 + currentKeyboardHandler = (e) => { 97 + if (!contextMenuEl) return; // menu was closed 98 + if (!["ArrowUp", "ArrowDown", "Enter", "Space", "Escape"].includes(e.code)) 99 + return; 58 100 59 101 e.preventDefault(); 60 - const idx = getRowIndex(row, DATA_ATTRS.INDEX); 102 + e.stopPropagation(); 103 + 104 + switch (e.code) { 105 + case "ArrowUp": 106 + focusedIndex = (focusedIndex - 1 + menuItems.length) % menuItems.length; 107 + updateMenuItemFocus(); 108 + break; 109 + 110 + case "ArrowDown": 111 + focusedIndex = (focusedIndex + 1) % menuItems.length; 112 + updateMenuItemFocus(); 113 + break; 114 + 115 + case "Enter": 116 + case "Space": 117 + menuItems[focusedIndex].click(); 118 + break; 119 + 120 + case "Escape": 121 + cleanupContextMenu(); 122 + break; 123 + } 124 + }; 125 + 126 + document.addEventListener("keydown", currentKeyboardHandler); 61 127 62 - if (!selectionManager.isSelected(idx)) { 63 - selectionManager.select(idx); 128 + // close menu on clicks outside it 129 + currentClickHandler = (e) => { 130 + if (!contextMenuEl?.contains(e.target)) { 131 + cleanupContextMenu(); 64 132 } 133 + }; 134 + document.addEventListener("click", currentClickHandler, { capture: true }); 65 135 66 - const selectedIndices = Array.from(selectionManager.getSelected()); 136 + // prevent default browser context menu from appearing 137 + currentSystemContextmenuEventHandler = (e) => { 138 + if ( 139 + !contextMenuEl?.contains(e.target) && 140 + !e.target.closest(`#${DOM_IDS.QUEUE_LIST}`) 141 + ) { 142 + e.preventDefault(); 143 + e.stopImmediatePropagation(); 144 + } 145 + }; 146 + document.addEventListener( 147 + "contextmenu", 148 + currentSystemContextmenuEventHandler, 149 + { 150 + capture: true, 151 + }, 152 + ); 153 + } 154 + 155 + // wrap handler to auto-cleanup 156 + const withContextMenuCleanup = 157 + (handler) => 158 + async (...args) => { 159 + try { 160 + await handler(...args); 161 + } finally { 162 + cleanupContextMenu(); 163 + } 164 + }; 165 + 166 + // get sort menu items 167 + function getSortMenuItems() { 168 + return { 169 + [STRINGS.CONTEXT_SORT_SHUFFLE]: withContextMenuCleanup(() => { 170 + sortQueue("shuffle"); 171 + updateQueue(); 172 + }), 173 + [STRINGS.CONTEXT_SORT_SONG_AZ]: withContextMenuCleanup(() => { 174 + sortQueue("title", true); 175 + updateQueue(); 176 + }), 177 + [STRINGS.CONTEXT_SORT_SONG_ZA]: withContextMenuCleanup(() => { 178 + sortQueue("title", false); 179 + updateQueue(); 180 + }), 181 + [STRINGS.CONTEXT_SORT_ARTIST_AZ]: withContextMenuCleanup(() => { 182 + sortQueue("artist", true); 183 + updateQueue(); 184 + }), 185 + [STRINGS.CONTEXT_SORT_ARTIST_ZA]: withContextMenuCleanup(() => { 186 + sortQueue("artist", false); 187 + updateQueue(); 188 + }), 189 + [STRINGS.CONTEXT_SORT_ALBUM_AZ]: withContextMenuCleanup(() => { 190 + sortQueue("album", true); 191 + updateQueue(); 192 + }), 193 + [STRINGS.CONTEXT_SORT_ALBUM_ZA]: withContextMenuCleanup(() => { 194 + sortQueue("album", false); 195 + updateQueue(); 196 + }), 197 + [STRINGS.CONTEXT_SORT_DURATION_SHORT_LONG]: withContextMenuCleanup(() => { 198 + sortQueue("duration", true); 199 + updateQueue(); 200 + }), 201 + [STRINGS.CONTEXT_SORT_DURATION_LONG_SHORT]: withContextMenuCleanup(() => { 202 + sortQueue("duration", false); 203 + updateQueue(); 204 + }), 205 + [STRINGS.CONTEXT_SORT_FAVORITED_FIRST]: withContextMenuCleanup(() => { 206 + sortQueue("favorited", false); 207 + updateQueue(); 208 + }), 209 + [STRINGS.CONTEXT_SORT_FAVORITED_LAST]: withContextMenuCleanup(() => { 210 + sortQueue("favorited", true); 211 + updateQueue(); 212 + }), 213 + }; 214 + } 67 215 68 - showContextMenu(e.clientX, e.clientY, { 69 - Play: () => { 70 - playQueueTrack(selectedIndices[0]); 71 - updateQueue(); 72 - }, 73 - "Play Next": () => { 74 - const insertPos = state.queueIndex >= 0 ? state.queueIndex + 1 : 0; 75 - moveQueueItems(state.queue, selectedIndices, insertPos, queueCallbacks); 76 - }, 77 - Favorite: async () => { 78 - await Promise.all( 79 - selectedIndices.map((i) => setFavoriteSong(state.queue[i], true)), 216 + // show context menu 217 + function showQueueContextMenuAtSelection(x, y, selectedIndices) { 218 + showContextMenu(x, y, { 219 + [STRINGS.CONTEXT_PLAY]: withContextMenuCleanup(() => { 220 + playQueueTrack(selectedIndices[0]); 221 + updateQueue(); 222 + }), 223 + [STRINGS.CONTEXT_PLAY_NEXT]: withContextMenuCleanup(() => { 224 + const insertPos = state.queueIndex >= 0 ? state.queueIndex + 1 : 0; 225 + moveQueueItems(state.queue, selectedIndices, insertPos, queueCallbacks); 226 + }), 227 + [STRINGS.CONTEXT_SORT]: () => { 228 + showContextMenu(x, y, getSortMenuItems()); 229 + }, 230 + [STRINGS.CONTEXT_FAVORITE]: withContextMenuCleanup(async () => { 231 + await Promise.all( 232 + selectedIndices.map((i) => setFavoriteSong(state.queue[i], true)), 233 + ); 234 + updateQueueDisplay(); 235 + }), 236 + [STRINGS.CONTEXT_UNFAVORITE]: withContextMenuCleanup(async () => { 237 + await Promise.all( 238 + selectedIndices.map((i) => setFavoriteSong(state.queue[i], false)), 239 + ); 240 + updateQueueDisplay(); 241 + }), 242 + [STRINGS.CONTEXT_MOVE_UP]: withContextMenuCleanup(() => { 243 + const firstIdx = Math.min(...selectedIndices); 244 + if (firstIdx > 0) { 245 + moveQueueItems( 246 + state.queue, 247 + selectedIndices, 248 + firstIdx - 1, 249 + queueCallbacks, 80 250 ); 81 - updateQueueDisplay(); 82 - cleanupContextMenu(); 83 - }, 84 - Unfavorite: async () => { 85 - await Promise.all( 86 - selectedIndices.map((i) => setFavoriteSong(state.queue[i], false)), 251 + } 252 + }), 253 + [STRINGS.CONTEXT_MOVE_DOWN]: withContextMenuCleanup(() => { 254 + const lastIdx = Math.max(...selectedIndices); 255 + if (lastIdx < state.queue.length - 1) { 256 + moveQueueItems( 257 + state.queue, 258 + selectedIndices, 259 + lastIdx + 2, 260 + queueCallbacks, 87 261 ); 88 - updateQueueDisplay(); 89 - cleanupContextMenu(); 90 - }, 91 - "Move Up": () => { 92 - const firstIdx = Math.min(...selectedIndices); 93 - if (firstIdx > 0) { 94 - moveQueueItems( 95 - state.queue, 96 - selectedIndices, 97 - firstIdx - 1, 98 - queueCallbacks, 99 - ); 100 - } 101 - }, 102 - "Move Down": () => { 103 - const lastIdx = Math.max(...selectedIndices); 104 - if (lastIdx < state.queue.length - 1) { 105 - moveQueueItems( 106 - state.queue, 107 - selectedIndices, 108 - lastIdx + 2, 109 - queueCallbacks, 110 - ); 111 - } 112 - }, 113 - Clear: clearSelectedRows, 114 - }); 262 + } 263 + }), 264 + [STRINGS.CONTEXT_CLEAR]: withContextMenuCleanup(() => { 265 + clearSelectedRows(); 266 + }), 115 267 }); 268 + } 116 269 117 - document.addEventListener( 270 + function setupQueueContextMenu() { 271 + ui.queueList.addEventListener( 118 272 "contextmenu", 119 273 (e) => { 120 - const isQueueTable = e.target.closest(`#${DOM_IDS.QUEUE_TABLE}`); 274 + const row = getClosestRow(e.target); 275 + if (!row) return; 121 276 122 - if (!isQueueTable && contextMenuEl) { 123 - e.preventDefault(); 124 - e.stopImmediatePropagation(); 277 + e.preventDefault(); 278 + e.stopPropagation(); 279 + 280 + const idx = getRowIndex(row, DATA_ATTRS.INDEX); 281 + if (!selectionManager.isSelected(idx)) { 282 + selectionManager.select(idx); 125 283 } 284 + 285 + const selectedIndices = Array.from(selectionManager.getSelected()); 286 + showQueueContextMenuAtSelection(e.clientX, e.clientY, selectedIndices); 126 287 }, 127 288 true, 128 289 );
+70 -25
src/js/draggable.js
··· 2 2 3 3 // setup drag and drop for queue reordering 4 4 function setupDragAndDrop() { 5 - const clearDragOver = () => 6 - clearRowClasses(ui.queueList, "tr", [ 7 - CLASSES.DRAG_OVER_ABOVE, 8 - CLASSES.DRAG_OVER_BELOW, 9 - CLASSES.DRAGGING, 10 - ]); 11 - const isDraggable = (row) => row && !row.classList.contains(CLASSES.DRAGGING); 12 5 const isDropBelowCenter = (e, row) => 13 6 e.clientY - row.getBoundingClientRect().top > row.offsetHeight / 2; 7 + 8 + let lastDragOverRow = null; 14 9 15 10 ui.queueList.addEventListener("dragstart", (e) => { 16 11 const row = getClosestRow(e.target); 17 - if (!row || selectionManager.isSelected(getRowIndex(row, DATA_ATTRS.INDEX))) 12 + if ( 13 + !row || 14 + row.classList.contains(CLASSES.DRAGGING) || 15 + selectionManager.isSelected(getRowIndex(row, DATA_ATTRS.INDEX)) 16 + ) 18 17 return; 19 18 20 - clearSelection(); 21 - selectRow(getRowIndex(row, DATA_ATTRS.INDEX)); 22 - updateRowClass(ui.queueList, getSelectedIndices(), CLASSES.DRAGGING, true); 19 + selectionManager.clear(); 20 + selectionManager.select(getRowIndex(row, DATA_ATTRS.INDEX)); 21 + updateRowClass( 22 + ui.queueList, 23 + selectionManager.getSelected(), 24 + CLASSES.DRAGGING, 25 + true, 26 + ); 23 27 e.dataTransfer.effectAllowed = "move"; 24 28 e.dataTransfer.setDragImage(row, 0, 0); 25 29 }); ··· 29 33 e.preventDefault(); 30 34 e.dataTransfer.dropEffect = "move"; 31 35 const row = getClosestRow(e.target); 32 - if (!isDraggable(row)) return; 36 + if (!row || row.classList.contains(CLASSES.DRAGGING)) return; 37 + 38 + const isBelow = isDropBelowCenter(e, row); 33 39 34 - clearRowClasses(ui.queueList, "tr", [ 35 - CLASSES.DRAG_OVER_ABOVE, 36 - CLASSES.DRAG_OVER_BELOW, 37 - ]); 38 - row.classList.add( 39 - isDropBelowCenter(e, row) 40 - ? CLASSES.DRAG_OVER_BELOW 41 - : CLASSES.DRAG_OVER_ABOVE, 42 - ); 40 + // only update if row changed or position within row changed 41 + if (lastDragOverRow !== row) { 42 + if (lastDragOverRow) { 43 + lastDragOverRow.classList.remove( 44 + CLASSES.DRAG_OVER_ABOVE, 45 + CLASSES.DRAG_OVER_BELOW, 46 + ); 47 + } 48 + row.classList.add( 49 + isBelow ? CLASSES.DRAG_OVER_BELOW : CLASSES.DRAG_OVER_ABOVE, 50 + ); 51 + lastDragOverRow = row; 52 + } else { 53 + const hasAbove = row.classList.contains(CLASSES.DRAG_OVER_ABOVE); 54 + const hasBelow = row.classList.contains(CLASSES.DRAG_OVER_BELOW); 55 + 56 + if ((isBelow && hasAbove) || (!isBelow && hasBelow)) { 57 + row.classList.remove(CLASSES.DRAG_OVER_ABOVE, CLASSES.DRAG_OVER_BELOW); 58 + row.classList.add( 59 + isBelow ? CLASSES.DRAG_OVER_BELOW : CLASSES.DRAG_OVER_ABOVE, 60 + ); 61 + } 62 + } 43 63 }); 44 64 45 65 // handle drop and complete the move 46 66 ui.queueList.addEventListener("drop", (e) => { 47 67 e.preventDefault(); 48 68 const row = getClosestRow(e.target); 49 - if (!isDraggable(row)) return; 69 + if (!row || row.classList.contains(CLASSES.DRAGGING)) return; 70 + 71 + if (lastDragOverRow) { 72 + lastDragOverRow.classList.remove( 73 + CLASSES.DRAG_OVER_ABOVE, 74 + CLASSES.DRAG_OVER_BELOW, 75 + CLASSES.DRAGGING, 76 + ); 77 + lastDragOverRow = null; 78 + } 79 + clearRowClasses(ui.queueList, "tr", CLASSES.DRAGGING); 50 80 51 - clearDragOver(); 52 81 const draggedIdx = getRowIndex(row, DATA_ATTRS.INDEX); 53 82 moveQueueItems( 54 83 state.queue, 55 - getSelectedIndices(), 84 + selectionManager.getSelected(), 56 85 isDropBelowCenter(e, row) ? draggedIdx + 1 : draggedIdx, 57 86 queueCallbacks, 58 87 ); 88 + 89 + // refocus after DOM render cycle completes 90 + requestAnimationFrame(() => { 91 + const mainEl = document.getElementById("queue"); 92 + if (mainEl) mainEl.focus(); 93 + }); 59 94 }); 60 95 61 96 // cleanup on drag end 62 - ui.queueList.addEventListener("dragend", clearDragOver); 97 + ui.queueList.addEventListener("dragend", () => { 98 + if (lastDragOverRow) { 99 + lastDragOverRow.classList.remove( 100 + CLASSES.DRAG_OVER_ABOVE, 101 + CLASSES.DRAG_OVER_BELOW, 102 + CLASSES.DRAGGING, 103 + ); 104 + lastDragOverRow = null; 105 + } 106 + clearRowClasses(ui.queueList, "tr", CLASSES.DRAGGING); 107 + }); 63 108 }
+53 -19
src/js/events.js
··· 18 18 } 19 19 20 20 document.addEventListener("DOMContentLoaded", async () => { 21 + // lock tab order initially and watch for new elements 22 + lockTabOrder(); 23 + tabOrderObserver = setupTabOrderObserver(); 24 + 21 25 // initialize selection manager for queue table 22 26 selectionManager = new SelectionManager(ui.queueList, { 23 27 rowSelector: "tr", ··· 25 29 selectedClass: CLASSES.SELECTED, 26 30 }); 27 31 32 + // make only #queue main and #library div tabbable 33 + const mainEl = document.getElementById("queue"); 34 + const libraryEl = document.getElementById("library"); 35 + 36 + if (mainEl) { 37 + mainEl.tabIndex = 0; 38 + mainEl.addEventListener("click", () => mainEl.focus()); 39 + } 40 + 41 + if (libraryEl) { 42 + libraryEl.tabIndex = 0; 43 + libraryEl.addEventListener("click", () => libraryEl.focus()); 44 + // restore library focus styling when library regains focus 45 + libraryEl.addEventListener("focus", () => { 46 + if (LibraryNavigator.currentFocusedItem) { 47 + LibraryNavigator.currentFocusedItem.classList.add("library-focused"); 48 + } 49 + }); 50 + } 51 + 28 52 // setup settings modal 29 53 setupSettings(); 30 54 ··· 56 80 ui.timeDisplay.textContent = `${current} / ${total}`; 57 81 updateLyricDisplay(ui.player.currentTime); 58 82 } 59 - updateMediaSessionPosition(); 60 83 }); 61 84 62 85 // playback controls ··· 64 87 ui.prevBtn.addEventListener("click", previousTrack); 65 88 ui.nextBtn.addEventListener("click", nextTrack); 66 89 67 - // progress slider (seek) - consolidate input/change handlers 90 + // progress slider 68 91 const seekHandler = (e) => { 69 92 ui.player.currentTime = parseFloat(e.target.value); 70 93 }; ··· 80 103 // sort queue 81 104 ui.sortBtn.addEventListener("click", (e) => { 82 105 const rect = e.target.getBoundingClientRect(); 83 - showContextMenu(rect.left, rect.bottom, { 84 - Shuffle: () => sortQueue("shuffle"), 85 - "Song (A-Z)": () => sortQueue("title", true), 86 - "Song (Z-A)": () => sortQueue("title", false), 87 - "Artist (A-Z)": () => sortQueue("artist", true), 88 - "Artist (Z-A)": () => sortQueue("artist", false), 89 - "Album (A-Z)": () => sortQueue("album", true), 90 - "Album (Z-A)": () => sortQueue("album", false), 91 - "Duration (short to long)": () => sortQueue("duration", true), 92 - "Duration (long to short)": () => sortQueue("duration", false), 93 - "Favorited First": () => sortQueue("favorited", false), 94 - "Favorited Last": () => sortQueue("favorited", true), 95 - }); 106 + showContextMenu(rect.left, rect.bottom, getSortMenuItems()); 96 107 }); 97 108 98 109 // clear queue ··· 110 121 const row = getClosestRow(e.target); 111 122 if (row) { 112 123 const idx = getRowIndex(row, DATA_ATTRS.INDEX); 113 - selectRow(idx, e.ctrlKey || e.metaKey, e.shiftKey); 124 + selectionManager.select(idx, { 125 + multi: e.ctrlKey || e.metaKey, 126 + shift: e.shiftKey, 127 + }); 114 128 } 115 129 return; 116 130 } ··· 130 144 if (row) { 131 145 const idx = getRowIndex(row, DATA_ATTRS.INDEX); 132 146 playQueueTrack(idx); 147 + selectionManager.clear(); 133 148 updateQueue(); 134 149 } 135 150 }); 136 151 137 - // deselect when clicking outside queue table or buttons 152 + // deselect selections when clicking empty space in their respective containers 138 153 document.addEventListener("click", (e) => { 139 154 const isInQueueTable = e.target.closest(`#${DOM_IDS.QUEUE_TABLE}`); 140 155 const isInContextMenu = e.target.closest(`#${DOM_IDS.CONTEXT_MENU}`); 141 156 const isButton = e.target.closest("button"); 157 + const isLink = e.target.closest("a"); 142 158 143 - if (!isInQueueTable && !isInContextMenu && !isButton) { 144 - clearSelection(); 159 + // clear queue selection when clicking empty space in queue (not on row/button/link) 160 + if ( 161 + mainEl?.contains(e.target) && 162 + !isInQueueTable && 163 + !isButton && 164 + !isLink && 165 + !isInContextMenu 166 + ) { 167 + selectionManager.clear(); 168 + } 169 + 170 + // clear library selection when clicking empty space in library (not on item/button/link) 171 + if ( 172 + libraryEl?.contains(e.target) && 173 + !isLink && 174 + !isButton && 175 + !isInContextMenu 176 + ) { 177 + LibraryNavigator.focusItem(null); 145 178 } 146 179 }); 147 180 ··· 154 187 setupDragAndDrop(); 155 188 setupQueueContextMenu(); 156 189 initVirtualScroller(); 190 + selectionManager.virtualScroller = virtualScroller; 157 191 setupKeyboardShortcuts(); 158 192 setupMediaSessionHandlers(); 159 193
+362 -12
src/js/input.js
··· 1 - // input handling 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 7 + const 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 11 + function 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 26 + function 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 + 52 + let tabOrderObserver; 53 + 54 + // queue navigation 2 55 3 56 // navigate queue selection with arrow keys 4 57 const navigateSelection = (offset, extend = false) => { ··· 15 68 selectionManager.select(nextIdx); 16 69 } 17 70 18 - ui.queueList 19 - .querySelector(`tr[${DATA_ATTRS.INDEX}="${nextIdx}"]`) 20 - ?.scrollIntoView({ block: "nearest" }); 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 76 + const elementCache = { queue: null, library: null }; 77 + 78 + // get cached element by id, refresh if detached from DOM 79 + function 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 + 87 + function getMainEl() { 88 + return getCachedElement("queue"); 89 + } 90 + 91 + function getLibraryEl() { 92 + return getCachedElement("library"); 93 + } 94 + 95 + // refocus the container after an action to keep keyboard shortcuts working 96 + function 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 111 + const keyboardActionHandlers = { 112 + play: (selectedIndices) => { 113 + if (!selectedIndices || selectedIndices.length === 0) return; 114 + playQueueTrack(selectedIndices[0]); 115 + updateQueue(); 116 + refocusContext(true, false); 117 + }, 118 + favorite: async (selectedIndices) => { 119 + await Promise.all( 120 + selectedIndices.map((i) => setFavoriteSong(state.queue[i], true)), 121 + ); 122 + updateQueueDisplay(); 123 + refocusContext(true, false); 124 + }, 125 + playNext: (selectedIndices) => { 126 + const insertPos = state.queueIndex >= 0 ? state.queueIndex + 1 : 0; 127 + moveQueueItems(state.queue, selectedIndices, insertPos, queueCallbacks); 128 + refocusContext(true, false); 129 + }, 130 + moveUp: (selectedIndices) => { 131 + const firstIdx = Math.min(...selectedIndices); 132 + if (firstIdx > 0) { 133 + moveQueueItems( 134 + state.queue, 135 + selectedIndices, 136 + firstIdx - 1, 137 + queueCallbacks, 138 + ); 139 + } 140 + refocusContext(true, false); 141 + }, 142 + moveDown: (selectedIndices) => { 143 + const lastIdx = Math.max(...selectedIndices); 144 + if (lastIdx < state.queue.length - 1) { 145 + moveQueueItems(state.queue, selectedIndices, lastIdx + 2, queueCallbacks); 146 + } 147 + refocusContext(true, false); 148 + }, 149 + delete: (selectedIndices) => { 150 + clearSelectedRows(); 151 + refocusContext(true, false); 152 + }, 153 + showContextMenu: (selectedIndices) => { 154 + // show context menu at the last selected row's position 155 + const lastIdx = selectedIndices[selectedIndices.length - 1]; 156 + const row = ui.queueList.querySelector( 157 + `tr[${DATA_ATTRS.INDEX}="${lastIdx}"]`, 158 + ); 159 + if (row) { 160 + const rect = row.getBoundingClientRect(); 161 + showQueueContextMenuAtSelection( 162 + rect.left, 163 + rect.top + rect.height, 164 + selectedIndices, 165 + ); 166 + } 167 + refocusContext(true, false); 168 + }, 21 169 }; 22 170 171 + // keyboard shortcuts 172 + 23 173 // setup keyboard shortcuts 24 174 function setupKeyboardShortcuts() { 175 + // setup keyboard help close button 176 + const closeKeyboardHelpBtn = document.getElementById( 177 + "close-keyboard-help-btn", 178 + ); 179 + if (closeKeyboardHelpBtn) { 180 + closeKeyboardHelpBtn.onclick = () => hideModal("keyboard-help-modal"); 181 + } 182 + 25 183 document.addEventListener("keydown", (e) => { 26 184 // skip if user is typing in an input field 27 185 if (document.activeElement.matches("input, textarea")) return; 28 186 187 + // skip keyboard shortcuts if a modal is open (let modal handle it) 188 + if (isModalOpen()) return; 189 + 190 + // cache element lookups for this event to avoid repeated DOM queries 191 + const mainEl = getMainEl(); 192 + const libraryEl = getLibraryEl(); 193 + const isInMain = mainEl && mainEl.contains(document.activeElement); 194 + const isInSidebar = libraryEl && libraryEl.contains(document.activeElement); 195 + 196 + if (!isInMain && !isInSidebar) return; 197 + 29 198 switch (e.code) { 30 199 case "Space": { 31 200 e.preventDefault(); ··· 35 204 36 205 case "Delete": 37 206 case "Backspace": { 207 + if (!isInMain) return; // queue only 38 208 e.preventDefault(); 39 209 if (selectionManager?.count() > 0) { 40 210 const selected = selectionManager.getSelected(); ··· 47 217 selectionManager.select(nextIdx); 48 218 } 49 219 } 220 + refocusContext(true, false); 50 221 break; 51 222 } 52 223 53 224 case "ArrowUp": { 54 225 e.preventDefault(); 55 - navigateSelection(-1, e.shiftKey); 226 + if (isInSidebar) { 227 + LibraryNavigator.navigate(-1); 228 + } else if (e.altKey) { 229 + selectionManager?.executeAction("moveUp", keyboardActionHandlers); 230 + } else { 231 + navigateSelection(-1, e.shiftKey); 232 + } 233 + refocusContext(isInMain, isInSidebar); 56 234 break; 57 235 } 58 236 59 237 case "ArrowDown": { 60 238 e.preventDefault(); 61 - navigateSelection(1, e.shiftKey); 239 + if (isInSidebar) { 240 + LibraryNavigator.navigate(1); 241 + } else if (e.altKey) { 242 + selectionManager?.executeAction("moveDown", keyboardActionHandlers); 243 + } else { 244 + navigateSelection(1, e.shiftKey); 245 + } 246 + refocusContext(isInMain, isInSidebar); 247 + break; 248 + } 249 + 250 + case "Home": { 251 + e.preventDefault(); 252 + if (isInSidebar) { 253 + LibraryNavigator.navigateFirst(); 254 + } else { 255 + if (e.shiftKey) { 256 + selectionManager?.select(0, { shift: true }); 257 + } else { 258 + selectionManager?.navigateFirst(); 259 + } 260 + } 261 + refocusContext(isInMain, isInSidebar); 262 + break; 263 + } 264 + 265 + case "End": { 266 + e.preventDefault(); 267 + if (isInSidebar) { 268 + LibraryNavigator.navigateLast(); 269 + } else { 270 + const lastIdx = (state?.queue?.length || 0) - 1; 271 + if (e.shiftKey) { 272 + selectionManager?.select(lastIdx, { shift: true }); 273 + } else { 274 + selectionManager?.navigateLast(); 275 + } 276 + } 277 + refocusContext(isInMain, isInSidebar); 278 + break; 279 + } 280 + 281 + case "PageUp": { 282 + e.preventDefault(); 283 + if (isInMain) { 284 + const currentIdx = selectionManager.lastSelected ?? 0; 285 + const nextIdx = Math.max(0, currentIdx - 10); 286 + if (e.shiftKey) { 287 + selectionManager?.select(nextIdx, { shift: true }); 288 + } else { 289 + selectionManager?.navigatePageUp(10); 290 + } 291 + } else if (isInSidebar) { 292 + LibraryNavigator.navigatePageUp(10); 293 + } 294 + refocusContext(isInMain, isInSidebar); 295 + break; 296 + } 297 + 298 + case "PageDown": { 299 + e.preventDefault(); 300 + if (isInMain) { 301 + const currentIdx = selectionManager.lastSelected ?? 0; 302 + const lastIdx = (state?.queue?.length || 0) - 1; 303 + const nextIdx = Math.min(lastIdx, currentIdx + 10); 304 + if (e.shiftKey) { 305 + selectionManager?.select(nextIdx, { shift: true }); 306 + } else { 307 + selectionManager?.navigatePageDown(10); 308 + } 309 + } else if (isInSidebar) { 310 + LibraryNavigator.navigatePageDown(10); 311 + } 312 + refocusContext(isInMain, isInSidebar); 62 313 break; 63 314 } 64 315 65 316 case "KeyA": { 66 - if (!(e.ctrlKey || e.metaKey)) return; 317 + if (!isInMain) return; // queue only 318 + if (!e.ctrlKey && !e.metaKey) return; // Ctrl+A (or Cmd+A on Mac) 67 319 e.preventDefault(); 68 320 if (state.queue.length > 0) { 69 321 selectionManager?.setSelection( 70 322 Array.from({ length: state.queue.length }, (_, i) => i), 71 323 ); 72 324 } 325 + refocusContext(true, false); 73 326 break; 74 327 } 75 328 76 329 case "Enter": { 77 330 e.preventDefault(); 78 - const selectedIndices = Array.from( 79 - selectionManager?.getSelected() || [], 80 - ); 81 - if (selectedIndices.length > 0) playQueueTrack(selectedIndices[0]); 331 + if (isInSidebar) { 332 + LibraryNavigator.toggleCurrent(); 333 + refocusContext(false, true); 334 + } else { 335 + selectionManager?.executeAction("play", keyboardActionHandlers); 336 + } 82 337 break; 83 338 } 84 339 85 340 case "Escape": { 86 341 e.preventDefault(); 87 342 cleanupContextMenu(); 88 - clearSelection(); 343 + selectionManager.clear(); 344 + refocusContext(isInMain, isInSidebar); 345 + break; 346 + } 347 + 348 + case "KeyP": { 349 + if (!isInSidebar) return; // library only 350 + if (e.altKey) { 351 + // Alt+P for play next in library 352 + e.preventDefault(); 353 + playLibraryItem(true); 354 + updateQueue(); 355 + } else { 356 + // P for add to queue 357 + e.preventDefault(); 358 + playLibraryItem(false); 359 + updateQueue(); 360 + } 361 + refocusContext(false, true); 362 + break; 363 + } 364 + 365 + case "Comma": { 366 + if (!(e.ctrlKey || e.metaKey)) return; // Ctrl+, for settings 367 + e.preventDefault(); 368 + const settingsModal = document.getElementById("settings-modal"); 369 + if (settingsModal) { 370 + showModal(settingsModal, { 371 + focusSelector: "input, button", 372 + closeOnClickOutside: true, 373 + }); 374 + } 375 + break; 376 + } 377 + 378 + case "ContextMenu": { 379 + if (!isInMain) return; // queue only 380 + e.preventDefault(); 381 + selectionManager?.executeAction( 382 + "showContextMenu", 383 + keyboardActionHandlers, 384 + ); 385 + break; 386 + } 387 + 388 + case "Slash": { 389 + if (!e.shiftKey) return; // Shift+/ for keyboard help (?) 390 + e.preventDefault(); 391 + const helpModalEl = document.getElementById("keyboard-help-modal"); 392 + if (!helpModalEl) break; 393 + 394 + if (helpModalEl.classList.contains("hidden")) { 395 + showModal(helpModalEl, { 396 + focusSelector: "button, input", 397 + closeOnClickOutside: true, 398 + }); 399 + } else { 400 + hideModal("keyboard-help-modal"); 401 + } 402 + break; 403 + } 404 + 405 + case "KeyR": { 406 + if (!e.shiftKey) return; // Shift+R for toggle loop (was Ctrl+R) 407 + e.preventDefault(); 408 + state.loop = !state.loop; 409 + ui.loopBtn.classList.toggle("active", state.loop); 410 + break; 411 + } 412 + 413 + case "KeyJ": { 414 + if (!isInMain) return; // queue only 415 + e.preventDefault(); 416 + if (e.altKey) { 417 + // Alt+J for previous track 418 + previousTrack(); 419 + } else { 420 + // J for seek -10s 421 + ui.player.currentTime = Math.max(0, ui.player.currentTime - 10); 422 + } 423 + break; 424 + } 425 + 426 + case "KeyL": { 427 + if (!isInMain) return; // queue only 428 + e.preventDefault(); 429 + if (e.altKey) { 430 + // Alt+L for next track 431 + nextTrack(); 432 + } else { 433 + // L for seek +10s 434 + ui.player.currentTime = Math.min( 435 + ui.player.duration, 436 + ui.player.currentTime + 10, 437 + ); 438 + } 89 439 break; 90 440 } 91 441 }
+216
src/js/library.js
··· 1 1 // library tree rendering and navigation 2 2 3 + // global map to store library items by ID for keyboard access 4 + const libraryItemsById = new Map(); 5 + 6 + // library keyboard navigation manager 7 + const LibraryNavigator = { 8 + currentFocusedItem: null, 9 + currentSection: "artists", // "artists" or "playlists" 10 + 11 + // get all focusable items in library (organized by section for natural navigation) 12 + getFocusableItems() { 13 + const items = []; 14 + 15 + // organize by section: artists header + items, then playlists header + items 16 + const artistsToggle = document.querySelector(`[data-section="artists"]`); 17 + if (artistsToggle) { 18 + items.push(artistsToggle); 19 + const artistItems = Array.from( 20 + document.querySelectorAll( 21 + `#${DOM_IDS.LIBRARY_TREE} .${CLASSES.TREE_TOGGLE}, #${DOM_IDS.LIBRARY_TREE} .${CLASSES.TREE_NAME}`, 22 + ), 23 + ); 24 + items.push(...artistItems); 25 + } 26 + 27 + const playlistsToggle = document.querySelector( 28 + `[data-section="playlists"]`, 29 + ); 30 + if (playlistsToggle) { 31 + items.push(playlistsToggle); 32 + const playlistItems = Array.from( 33 + document.querySelectorAll( 34 + `#${DOM_IDS.PLAYLISTS_TREE} .${CLASSES.TREE_TOGGLE}, #${DOM_IDS.PLAYLISTS_TREE} .${CLASSES.TREE_NAME}`, 35 + ), 36 + ); 37 + items.push(...playlistItems); 38 + } 39 + 40 + return items; 41 + }, 42 + 43 + // set focus to specific item 44 + focusItem(element) { 45 + if (this.currentFocusedItem) { 46 + this.currentFocusedItem.classList.remove("library-focused"); 47 + } 48 + if (element) { 49 + element.classList.add("library-focused"); 50 + element.scrollIntoView({ block: "nearest" }); 51 + this.currentFocusedItem = element; 52 + } 53 + }, 54 + 55 + // focus first item (first section header) 56 + navigateFirst() { 57 + const items = this.getFocusableItems(); 58 + if (items.length > 0) this.focusItem(items[0]); 59 + }, 60 + 61 + navigateLast() { 62 + const items = this.getFocusableItems(); 63 + if (items.length > 0) this.focusItem(items[items.length - 1]); 64 + }, 65 + 66 + // navigate up/down through items 67 + navigate(direction) { 68 + const items = this.getFocusableItems(); 69 + if (items.length === 0) return; 70 + 71 + if (!this.currentFocusedItem) { 72 + this.focusItem(items[0]); 73 + return; 74 + } 75 + 76 + const currentIndex = items.indexOf(this.currentFocusedItem); 77 + const newIndex = currentIndex + direction; 78 + if (newIndex >= 0 && newIndex < items.length) { 79 + this.focusItem(items[newIndex]); 80 + } 81 + }, 82 + 83 + // navigate by page (jump multiple items) 84 + navigatePageUp(pageSize = 10) { 85 + const items = this.getFocusableItems(); 86 + if (items.length === 0) return; 87 + 88 + if (!this.currentFocusedItem) { 89 + this.focusItem(items[0]); 90 + return; 91 + } 92 + 93 + const currentIndex = items.indexOf(this.currentFocusedItem); 94 + const newIndex = Math.max(0, currentIndex - pageSize); 95 + this.focusItem(items[newIndex]); 96 + }, 97 + 98 + navigatePageDown(pageSize = 10) { 99 + const items = this.getFocusableItems(); 100 + if (items.length === 0) return; 101 + 102 + if (!this.currentFocusedItem) { 103 + this.focusItem(items[0]); 104 + return; 105 + } 106 + 107 + const currentIndex = items.indexOf(this.currentFocusedItem); 108 + const newIndex = Math.min(items.length - 1, currentIndex + pageSize); 109 + this.focusItem(items[newIndex]); 110 + }, 111 + 112 + // toggle expand/collapse on current item (if it's a tree toggle) 113 + toggleCurrent() { 114 + if (!this.currentFocusedItem) return; 115 + const isToggle = this.currentFocusedItem.classList.contains( 116 + CLASSES.TREE_TOGGLE, 117 + ); 118 + if (isToggle) { 119 + this.currentFocusedItem.click(); 120 + } else if (this.currentFocusedItem.classList.contains("section-toggle")) { 121 + // also allow toggling section headers 122 + this.currentFocusedItem.click(); 123 + } 124 + }, 125 + 126 + // get data (item id and type) from currently focused item 127 + getCurrentItemData() { 128 + if (!this.currentFocusedItem) return null; 129 + const li = this.currentFocusedItem.closest("li"); 130 + if (!li) return null; 131 + return { 132 + itemId: li.dataset.itemId, 133 + section: this.determineSection(), 134 + }; 135 + }, 136 + 137 + // determine which section (artists or playlists) the current item is in 138 + determineSection() { 139 + if (!this.currentFocusedItem) return "artists"; 140 + const inArtists = 141 + this.currentFocusedItem.closest(`#${DOM_IDS.LIBRARY_TREE}`) !== null; 142 + return inArtists ? "artists" : "playlists"; 143 + }, 144 + }; 145 + 146 + // determine item type based on nesting level in tree 147 + function getLibraryItemType(li, section) { 148 + let level = 0; 149 + let parent = li.parentElement; 150 + const container = section === "artists" ? ui.artistsTree : ui.playlistsTree; 151 + while (parent && parent !== container) { 152 + if ( 153 + parent.classList.contains(CLASSES.NESTED) || 154 + parent.classList.contains(CLASSES.NESTED_SONGS) 155 + ) { 156 + level++; 157 + } 158 + parent = parent.parentElement; 159 + } 160 + 161 + // map level to type: for artists tree use level, for playlists distinguish between playlist container and songs 162 + if (section === "playlists") { 163 + return level >= 1 ? "song" : "playlist"; 164 + } else if (level === 1) { 165 + return "album"; 166 + } else if (level >= 2) { 167 + return "song"; 168 + } 169 + return "artist"; 170 + } 171 + 172 + // look up a song in the library cache by ID 173 + function lookupSongInLibrary(songId) { 174 + return libraryItemsById.get(songId); 175 + } 176 + 177 + // trigger play or play next for library item 178 + function playLibraryItem(playNext = false) { 179 + const data = LibraryNavigator.getCurrentItemData(); 180 + if (!data || !data.itemId) return; 181 + 182 + const link = LibraryNavigator.currentFocusedItem; 183 + const li = link?.closest("li"); 184 + if (!li) return; 185 + 186 + const type = getLibraryItemType(li, data.section); 187 + 188 + // for songs, look up the full object from library cache or create minimal one 189 + let itemData = data.itemId; 190 + if (type === "song") { 191 + const song = lookupSongInLibrary(data.itemId); 192 + if (song) { 193 + itemData = song; 194 + } else { 195 + // if not in cache, create a minimal song object with just ID 196 + // the server will have more info, or queue will show it with limited data 197 + itemData = { id: data.itemId }; 198 + } 199 + } 200 + 201 + // get handler and execute 202 + const handler = playNext ? playNextByType[type] : playByType[type]; 203 + 204 + if (handler) { 205 + handler(itemData); 206 + } 207 + } 208 + 209 + const playByType = { 210 + artist: (id) => addArtistToQueue(id), 211 + album: (id) => addAlbumToQueue(id), 212 + playlist: (id) => addPlaylistToQueue(id), 213 + song: (song) => addSongToQueue(song), 214 + }; 215 + 3 216 const playNextByType = { 4 217 artist: (id) => 5 218 addToQueue(() => state.api.getArtist(id), SONG_EXTRACTORS.artist, true), ··· 128 341 129 342 // build a tree item with callback wrapping 130 343 function buildTreeItem(item, mapped, onToggle, onAction, onPlayNext, artType) { 344 + // store item data by ID for keyboard access 345 + libraryItemsById.set(item.id, item); 346 + 131 347 return createTreeItem( 132 348 mapped.label, 133 349 mapped.cover || null,
+154
src/js/modal.js
··· 1 + // modal utilities 2 + 3 + // track open modals and their cleanup functions 4 + const modalRegistry = new Map(); 5 + let focusedBeforeModal = null; 6 + 7 + // show a modal with automatic focus trap and cleanup management 8 + function showModal(modalEl, options = {}) { 9 + if (!modalEl) return; 10 + 11 + const { focusSelector = "button, input, [href]", onShow = null } = options; 12 + 13 + // prevent duplicate opens 14 + if (modalRegistry.has(modalEl.id)) { 15 + return; 16 + } 17 + 18 + // store focused element before opening modal 19 + focusedBeforeModal = document.activeElement; 20 + 21 + const cleanup = { 22 + focusTrap: null, 23 + escapeHandler: null, 24 + clickHandler: null, 25 + }; 26 + 27 + // show modal 28 + modalEl.classList.remove("hidden"); 29 + 30 + // setup focus trap 31 + cleanup.focusTrap = trapModalFocus(modalEl); 32 + 33 + // handle Escape key to close 34 + cleanup.escapeHandler = (e) => { 35 + if (e.code === "Escape") { 36 + e.preventDefault(); 37 + hideModal(modalEl.id); 38 + } 39 + }; 40 + document.addEventListener("keydown", cleanup.escapeHandler); 41 + 42 + // handle clicks outside modal to close (optional - not all modals do this) 43 + if (options.closeOnClickOutside) { 44 + cleanup.clickHandler = (e) => { 45 + if (e.target === modalEl) { 46 + hideModal(modalEl.id); 47 + } 48 + }; 49 + document.addEventListener("click", cleanup.clickHandler); 50 + } 51 + 52 + // store cleanup functions 53 + modalRegistry.set(modalEl.id, cleanup); 54 + 55 + // call user callback 56 + onShow?.(); 57 + 58 + // focus first focusable element 59 + const focusable = modalEl.querySelector(focusSelector); 60 + if (focusable) focusable.focus(); 61 + } 62 + 63 + // hide a modal and clean up all listeners 64 + function hideModal(modalId) { 65 + const cleanup = modalRegistry.get(modalId); 66 + if (!cleanup) return; 67 + 68 + const modalEl = document.getElementById(modalId); 69 + if (!modalEl) { 70 + modalRegistry.delete(modalId); 71 + return; 72 + } 73 + 74 + // call cleanup functions 75 + cleanup.focusTrap?.(); 76 + if (cleanup.escapeHandler) { 77 + document.removeEventListener("keydown", cleanup.escapeHandler); 78 + } 79 + if (cleanup.clickHandler) { 80 + document.removeEventListener("click", cleanup.clickHandler); 81 + } 82 + 83 + // hide modal 84 + modalEl.classList.add("hidden"); 85 + 86 + // restore focus to what was focused before modal opened 87 + if (focusedBeforeModal && document.body.contains(focusedBeforeModal)) { 88 + focusedBeforeModal.focus(); 89 + } 90 + focusedBeforeModal = null; 91 + 92 + // remove from registry 93 + modalRegistry.delete(modalId); 94 + } 95 + 96 + // check if any modal is open (includes context menu which isn't in registry) 97 + function isModalOpen() { 98 + if (modalRegistry.size > 0) return true; 99 + const contextMenu = document.getElementById("context-menu"); 100 + return contextMenu && document.body.contains(contextMenu); 101 + } 102 + 103 + // trap keyboard focus within a modal 104 + function trapModalFocus(modalEl) { 105 + // get all focusable elements 106 + const focusableElements = Array.from( 107 + modalEl.querySelectorAll( 108 + "button, [href], input, select, textarea, [role='button'], [role='menuitem']", 109 + ), 110 + ); 111 + 112 + if (focusableElements.length === 0) return null; 113 + 114 + const storedTabIndices = new Map(); 115 + focusableElements.forEach((el) => { 116 + storedTabIndices.set(el, el.tabIndex); 117 + el.tabIndex = 0; 118 + }); 119 + 120 + const firstElement = focusableElements[0]; 121 + const lastElement = focusableElements[focusableElements.length - 1]; 122 + 123 + const handleKeydown = (e) => { 124 + // only trap Tab key 125 + if (e.code !== "Tab") return; 126 + 127 + // only trap if focus is actually in this modal 128 + if (!modalEl.contains(document.activeElement)) return; 129 + 130 + // wrap focus at modal boundaries 131 + if (e.shiftKey) { 132 + if (document.activeElement === firstElement) { 133 + e.preventDefault(); 134 + lastElement.focus(); 135 + } 136 + } else { 137 + if (document.activeElement === lastElement) { 138 + e.preventDefault(); 139 + firstElement.focus(); 140 + } 141 + } 142 + }; 143 + 144 + document.addEventListener("keydown", handleKeydown); 145 + 146 + // return cleanup function 147 + return () => { 148 + document.removeEventListener("keydown", handleKeydown); 149 + // restore original tabindex values 150 + focusableElements.forEach((el) => { 151 + el.tabIndex = storedTabIndices.get(el); 152 + }); 153 + }; 154 + }
+20 -32
src/js/player.js
··· 10 10 ui.trackTitle.textContent = song.title || STRINGS.UNKNOWN_TRACK; 11 11 ui.trackArtist.textContent = song.artist || STRINGS.UNKNOWN_ARTIST; 12 12 document.title = `${song.title || STRINGS.UNKNOWN_TRACK} • tinysub`; 13 - setCoverArt(song); 14 - ui.player.src = state.api.getStreamUrl(song.id); 15 - ui.player.play(); 16 - loadLyricsForSong(song); 17 - highlightCurrentTrack(); 18 - updateMediaSession(song); 19 - } 20 - 21 - // set cover art in player UI 22 - function setCoverArt(song) { 23 - if (song?.coverArt) { 13 + if (song.coverArt) { 24 14 loadCachedImage(ui.coverArt, song.coverArt, "artNowPlaying"); 25 15 } else { 26 16 ui.coverArt.src = ""; 27 17 ui.coverArt.srcset = ""; 28 18 } 19 + ui.player.src = state.api.getStreamUrl(song.id); 20 + ui.player.play(); 21 + loadLyricsForSong(song); 22 + highlightCurrentTrack(); 23 + updateMediaSession(song); 24 + withMediaSession((ms) => (ms.playbackState = "playing")); 29 25 } 30 26 31 27 // update play button icon based on playback state 32 28 const updatePlayIcon = () => { 33 - const iconPath = ui.player.paused ? ICONS.PLAY : ICONS.PAUSE; 34 - const label = ui.player.paused ? "play" : "pause"; 29 + const isPaused = ui.player.paused; 30 + const iconPath = isPaused ? ICONS.PLAY : ICONS.PAUSE; 31 + const label = isPaused ? "play" : "pause"; 35 32 ui.playBtn.innerHTML = `<img src="${iconPath}" alt="${label}" />`; 33 + if (ui.player.src) { 34 + withMediaSession( 35 + (ms) => (ms.playbackState = isPaused ? "paused" : "playing"), 36 + ); 37 + } 36 38 }; 37 39 38 40 // move to next or previous track in queue ··· 109 111 110 112 // end queue playback and update UI 111 113 function endQueue() { 114 + state.queueIndex = -1; 112 115 clearPlayerUI(); 113 116 highlightCurrentTrack(); 114 117 } ··· 125 128 ui.progress.value = 0; 126 129 ui.timeDisplay.textContent = "0:00 / 0:00"; 127 130 updatePlayIcon(); 128 - withMediaSession((ms) => (ms.metadata = null)); 131 + withMediaSession((ms) => { 132 + ms.metadata = null; 133 + ms.playbackState = "none"; 134 + }); 129 135 } 130 136 131 137 // move to next track when current song ends ··· 178 184 ] 179 185 : [], 180 186 }); 181 - updateMediaSessionPosition(); 182 187 }); 183 188 }; 184 - 185 - // throttled update of playback position for media session 186 - const updateMediaSessionPosition = (() => { 187 - let lastUpdate = 0; 188 - return () => { 189 - const now = Date.now(); 190 - if (now - lastUpdate < 200) return; 191 - lastUpdate = now; 192 - withMediaSession((ms) => { 193 - ms.setPositionState({ 194 - duration: ui.player.duration || 0, 195 - playbackRate: 1, 196 - position: ui.player.currentTime || 0, 197 - }); 198 - }); 199 - }; 200 - })();
+8 -5
src/js/queue.js
··· 122 122 state.queueIndex = state.queue.indexOf(currentTrack); 123 123 } 124 124 125 - saveQueue(); 126 125 updateQueue(); 127 126 } 128 127 ··· 286 285 (idx) => createQueueRow(state.queue[idx], idx), 287 286 { 288 287 onScroll: () => { 289 - updateSelectionUI(); 288 + selectionManager.updateUI(); 290 289 highlightCurrentTrack(); 291 290 }, 292 291 }, ··· 303 302 304 303 // clear selected rows 305 304 function clearSelectedRows() { 306 - const toRemove = getSelectedIndices(); 305 + const toRemove = selectionManager.getSelected(); 307 306 if (toRemove.length === 0) return; 308 307 309 308 const toRemoveSet = new Set(toRemove); ··· 328 327 handleCurrentTrackDeleted(); 329 328 } 330 329 331 - clearSelection(); 330 + // pause mutation observer to prevent interference during DOM update 331 + tabOrderObserver?.pauseUpdates(); 332 + selectionManager.clear(); 332 333 updateQueue(); 333 334 highlightCurrentTrack(); 335 + // resume after update completes 336 + tabOrderObserver?.resumeUpdates(); 334 337 } 335 338 336 339 // remove a song by index, adjusting queue index if necessary ··· 499 502 highlightCurrentTrack(); 500 503 } 501 504 502 - updateQueue(); 505 + updateQueueDisplay(); 503 506 }, 504 507 // toggle favorite status 505 508 [CLASSES.QUEUE_FAVORITE]: async (idx) => {
+57 -17
src/js/selection-manager.js src/js/selection.js
··· 10 10 this.indexAttribute = options.indexAttribute || "data-index"; 11 11 this.selectedClass = options.selectedClass || "selected"; 12 12 this.listeners = []; 13 + this.virtualScroller = options.virtualScroller || null; 13 14 } 14 15 15 16 getRowIndex(row) { ··· 46 47 47 48 this.lastSelected = index; 48 49 this.updateUI(); 50 + this.focusSelectedRow(); 49 51 this.notifyListeners(); 50 52 } 51 53 ··· 103 105 } 104 106 105 107 setSelection(indices) { 106 - // set selection from external source (for programmatic updates) 108 + // set selection from external source 107 109 this.selected.clear(); 108 110 indices.forEach((idx) => this.selected.add(idx)); 109 111 this.lastSelected = indices.length > 0 ? indices[indices.length - 1] : null; 110 112 this.shiftAnchor = null; 111 113 this.updateUI(); 114 + this.focusSelectedRow(); 112 115 this.notifyListeners(); 113 116 } 114 - } 115 117 116 - // wrapper functions for legacy compatibility and convenience 117 - function selectRow(index, multiSelect = false, shiftSelect = false) { 118 - selectionManager.select(index, { multi: multiSelect, shift: shiftSelect }); 119 - } 118 + focusSelectedRow() { 119 + // scroll selected row into view without focusing individual rows 120 + // (focus stays on container for keyboard nav during virtual scrolling) 121 + if (this.lastSelected === null) return; 122 + const rows = this.container.querySelectorAll(this.rowSelector); 123 + const row = Array.from(rows).find( 124 + (r) => this.getRowIndex(r) === this.lastSelected, 125 + ); 126 + if (row) { 127 + row.scrollIntoView({ block: "nearest", behavior: "auto" }); 128 + } else if (this.virtualScroller && this.virtualScroller.rowHeight > 0) { 129 + // row is outside visible range, use actual measured row height 130 + const targetScrollTop = Math.max( 131 + 0, 132 + this.lastSelected * this.virtualScroller.rowHeight - 133 + this.virtualScroller.container.clientHeight / 2, 134 + ); 135 + this.virtualScroller.container.scrollTop = targetScrollTop; 136 + } 137 + } 120 138 121 - function clearSelection() { 122 - // clear all selected rows 123 - selectionManager.clear(); 124 - } 139 + navigateTo(index) { 140 + // navigate to a specific index 141 + const maxIdx = Math.max(0, (state?.queue?.length || 0) - 1); 142 + const boundedIdx = Math.min(Math.max(0, index), maxIdx); 143 + this.select(boundedIdx); 144 + } 145 + 146 + navigateFirst() { 147 + // navigate to first item 148 + this.navigateTo(0); 149 + } 150 + 151 + navigateLast() { 152 + // navigate to last item 153 + this.navigateTo((state?.queue?.length || 0) - 1); 154 + } 155 + 156 + navigatePageUp(pageSize = 10) { 157 + // navigate up by page size 158 + const currentIdx = this.lastSelected ?? 0; 159 + this.navigateTo(currentIdx - pageSize); 160 + } 125 161 126 - function getSelectedIndices() { 127 - // get all selected row indices 128 - return selectionManager.getSelected(); 129 - } 162 + navigatePageDown(pageSize = 10) { 163 + // navigate down by page size 164 + const currentIdx = this.lastSelected ?? 0; 165 + this.navigateTo(currentIdx + pageSize); 166 + } 130 167 131 - // export selection manager methods through wrapper 132 - function updateSelectionUI() { 133 - selectionManager.updateUI(); 168 + executeAction(actionName, handlers = {}) { 169 + // execute an action with provided handler functions 170 + const selected = this.getSelected(); 171 + if (selected.length === 0) return; 172 + handlers[actionName]?.(selected); 173 + } 134 174 }
+39 -56
src/js/settings.js
··· 1 1 // settings 2 2 3 - // load settings from localStorage and apply to state 4 - function loadSettings() { 5 - const saved = localStorage.getItem("tinysub_settings"); 6 - if (saved) { 7 - try { 8 - const loaded = JSON.parse(saved); 9 - state.settings = { ...state.settings, ...loaded }; 10 - } catch { 11 - // ignore parse errors, use defaults 12 - } 13 - } 14 - applySettings(); 15 - } 16 - 17 - // persist settings to localStorage 18 - function saveSettings() { 19 - localStorage.setItem("tinysub_settings", JSON.stringify(state.settings)); 20 - } 3 + // art type configuration with CSS and HTML naming mappings 4 + const ART_TYPES = [ 5 + { name: "artist", key: "artArtist" }, 6 + { name: "album", key: "artAlbum" }, 7 + { name: "song", key: "artSong" }, 8 + { name: "now-playing", key: "artNowPlaying" }, 9 + ]; 21 10 22 11 // apply settings to CSS custom properties 23 12 function applySettings() { 24 - const mappings = { 25 - artist: "artArtist", 26 - album: "artAlbum", 27 - song: "artSong", 28 - "now-playing": "artNowPlaying", 29 - }; 30 - Object.entries(mappings).forEach(([name, key]) => { 13 + ART_TYPES.forEach(({ name, key }) => { 31 14 const size = state.settings[key]; 32 15 document.documentElement.style.setProperty( 33 16 `--art-${name}`, ··· 45 28 const closeBtn = document.getElementById("close-settings-btn"); 46 29 const scrobbleToggle = document.getElementById("scrobbling-toggle"); 47 30 48 - loadSettings(); 31 + // load and apply settings from localStorage 32 + const saved = localStorage.getItem("tinysub_settings"); 33 + if (saved) { 34 + try { 35 + const loaded = JSON.parse(saved); 36 + state.settings = { ...state.settings, ...loaded }; 37 + } catch { 38 + // ignore parse errors, use defaults 39 + } 40 + } 41 + applySettings(); 42 + 49 43 scrobbleToggle.checked = state.settings.scrobbling; 50 44 51 - const artTypes = [ 52 - { name: "artist", key: "artArtist" }, 53 - { name: "album", key: "artAlbum" }, 54 - { name: "song", key: "artSong" }, 55 - { name: "now-playing", key: "artNowPlaying" }, 56 - ]; 57 - 58 - const sliderConfig = artTypes.map(({ name, key }) => ({ 45 + const sliderConfig = ART_TYPES.map(({ name, key }) => ({ 59 46 input: document.getElementById(`${name}-size`), 60 47 display: document.getElementById(`${name}-size-display`), 61 48 key, 62 49 })); 63 50 51 + // initialize sliders and setup listeners 64 52 sliderConfig.forEach(({ input, display, key }) => { 65 53 input.value = state.settings[key]; 66 54 display.textContent = input.value; 67 - }); 68 55 69 - const toggleModal = (show) => modal.classList.toggle("hidden", !show); 70 - settingsBtn.addEventListener("click", () => toggleModal(true)); 71 - closeBtn.addEventListener("click", () => toggleModal(false)); 72 - modal.addEventListener("click", (e) => { 73 - if (e.target === modal) toggleModal(false); 74 - }); 75 - 76 - scrobbleToggle.addEventListener("change", () => { 77 - state.settings.scrobbling = scrobbleToggle.checked; 78 - saveSettings(); 79 - }); 80 - 81 - sliderConfig.forEach(({ input, display, key }) => { 82 56 input.addEventListener("input", () => { 83 57 display.textContent = input.value; 84 58 }); ··· 86 60 input.addEventListener("change", () => { 87 61 state.settings[key] = parseInt(input.value); 88 62 applySettings(); 89 - saveSettings(); 90 - triggerQueueRefresh(); 63 + localStorage.setItem("tinysub_settings", JSON.stringify(state.settings)); 64 + updateQueueDisplay(); 65 + renderLibraryTree(); 66 + renderPlaylistsTree(); 67 + }); 68 + }); 69 + 70 + // modal management 71 + settingsBtn.addEventListener("click", () => { 72 + showModal(modal, { 73 + focusSelector: "input, button", 74 + closeOnClickOutside: true, 91 75 }); 92 76 }); 93 - } 77 + closeBtn.addEventListener("click", () => hideModal(DOM_IDS.SETTINGS_MODAL)); 94 78 95 - // trigger refresh of queue and library when art sizes change 96 - function triggerQueueRefresh() { 97 - updateQueueDisplay(); 98 - renderLibraryTree(); 99 - renderPlaylistsTree(); 79 + scrobbleToggle.addEventListener("change", () => { 80 + state.settings.scrobbling = scrobbleToggle.checked; 81 + localStorage.setItem("tinysub_settings", JSON.stringify(state.settings)); 82 + }); 100 83 }
+21
src/js/strings/en.js
··· 7 7 UNKNOWN_ARTIST: "unknown artist", 8 8 SERVER_URL_EMPTY: "server URL cannot be empty", 9 9 SERVER_URL_REQUIRED: "server URL is required", 10 + // context menu 11 + CONTEXT_PLAY: "play", 12 + CONTEXT_PLAY_NEXT: "play next", 13 + CONTEXT_FAVORITE: "favorite", 14 + CONTEXT_UNFAVORITE: "unfavorite", 15 + CONTEXT_MOVE_UP: "move up", 16 + CONTEXT_MOVE_DOWN: "move down", 17 + CONTEXT_CLEAR: "clear", 18 + CONTEXT_SORT: "sort", 19 + // context menu - sort 20 + CONTEXT_SORT_SHUFFLE: "shuffle", 21 + CONTEXT_SORT_SONG_AZ: "song (a-z)", 22 + CONTEXT_SORT_SONG_ZA: "song (z-a)", 23 + CONTEXT_SORT_ARTIST_AZ: "artist (a-z)", 24 + CONTEXT_SORT_ARTIST_ZA: "artist (z-a)", 25 + CONTEXT_SORT_ALBUM_AZ: "album (a-z)", 26 + CONTEXT_SORT_ALBUM_ZA: "album (z-a)", 27 + CONTEXT_SORT_DURATION_SHORT_LONG: "duration (short to long)", 28 + CONTEXT_SORT_DURATION_LONG_SHORT: "duration (long to short)", 29 + CONTEXT_SORT_FAVORITED_FIRST: "favorited first", 30 + CONTEXT_SORT_FAVORITED_LAST: "favorited last", 10 31 };