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.

refactor: cleanup

+93 -86
+6 -6
src/css/components.css
··· 144 144 margin-block-start: 0.25rem; 145 145 } 146 146 147 - #sidebar #library .tree-toggle.library-focused, 148 - #sidebar #library .tree-name.library-focused, 149 - #sidebar #library .section-toggle.library-focused { 147 + #sidebar #library .tree-toggle.focused, 148 + #sidebar #library .tree-name.focused, 149 + #sidebar #library .section-toggle.focused { 150 150 background: Highlight; 151 151 color: HighlightText; 152 152 } 153 153 154 - #sidebar #library:not(:focus-within) .tree-toggle.library-focused, 155 - #sidebar #library:not(:focus-within) .tree-name.library-focused, 156 - #sidebar #library:not(:focus-within) .section-toggle.library-focused { 154 + #sidebar #library:not(:focus-within) .tree-toggle.focused, 155 + #sidebar #library:not(:focus-within) .tree-name.focused, 156 + #sidebar #library:not(:focus-within) .section-toggle.focused { 157 157 background: GrayText; 158 158 } 159 159
+1 -1
src/index.html
··· 299 299 <script src="js/state.js"></script> 300 300 <script src="js/api.js"></script> 301 301 <script src="js/queue-storage.js"></script> 302 - <script src="js/virtual-scroll.js"></script> 302 + <script src="js/queue-virtualscroll.js"></script> 303 303 <script src="js/queue.js"></script> 304 304 <script src="js/library-selection.js"></script> 305 305 <script src="js/library.js"></script>
+8 -4
src/js/constants.js
··· 1 1 // css classes and ui constants 2 2 3 3 const CLASSES = { 4 + ACTIVE: "active", 4 5 CONTEXT_MENU_ITEM: "context-menu-item", 5 6 CURRENTLY_PLAYING: "currently-playing", 6 7 DISABLED: "disabled", ··· 9 10 DRAGGING: "dragging", 10 11 ERROR: "error", 11 12 FAVORITED: "favorited", 13 + FOCUSED: "focused", 14 + HIDDEN: "hidden", 12 15 NESTED_SONGS: "nested-songs", 13 16 NESTED: "nested", 14 17 QUEUE_CLEAR: "queue-clear", ··· 19 22 QUEUE_PLAY_NEXT: "queue-play-next", 20 23 QUEUE_PLAY: "queue-play", 21 24 SELECTED: "selected", 25 + STRIPE: "stripe", 22 26 TREE_ITEM: "tree-item", 23 27 TREE_NAME: "tree-name", 24 28 TREE_TOGGLE: "tree-toggle", 25 29 }; 26 30 27 - const DATA_ATTRS = { 28 - INDEX: "data-index", 29 - }; 30 - 31 31 const DOM_IDS = { 32 32 AUTH_ERROR: "auth-error", 33 33 AUTH_MODAL: "auth-modal", 34 34 CLEAR_BTN: "clear-btn", 35 + CLOSE_KEYBOARD_HELP_BTN: "close-keyboard-help-btn", 35 36 CONTEXT_MENU: "context-menu", 36 37 COVER_ART: "cover-art", 38 + KEYBOARD_HELP_MODAL: "keyboard-help-modal", 37 39 LIBRARY_TREE: "artists-tree", 40 + LIBRARY: "library", 38 41 LOGIN_FORM: "login-form", 39 42 LOOP_BTN: "loop-btn", 40 43 NEXT_BTN: "next-btn", ··· 47 50 QUEUE_COUNT: "queue-count", 48 51 QUEUE_LIST: "queue-list", 49 52 QUEUE_TABLE: "queue-table", 53 + QUEUE: "queue", 50 54 SECTION_TOGGLE: "section-toggle", 51 55 SERVER_INPUT: "server", 52 56 SETTINGS_BTN: "settings-btn",
+3 -3
src/js/contextmenu.js
··· 94 94 95 95 const updateMenuItemFocus = () => { 96 96 const focused = contextMenuEl.querySelector(".focused"); 97 - if (focused) focused.classList.remove("focused"); 98 - menuItems[focusedIndex].classList.add("focused"); 97 + if (focused) focused.classList.remove(CLASSES.FOCUSED); 98 + menuItems[focusedIndex].classList.add(CLASSES.FOCUSED); 99 99 }; 100 100 101 101 currentKeyboardHandler = (e) => { ··· 278 278 e.preventDefault(); 279 279 e.stopPropagation(); 280 280 281 - const idx = parseInt(row.getAttribute(DATA_ATTRS.INDEX)); 281 + const idx = parseInt(row.getAttribute("data-index")); 282 282 if (!queueSelection.isSelected(idx)) { 283 283 queueSelection.select(idx); 284 284 }
+7 -7
src/js/events.js
··· 25 25 // initialize queueselection 26 26 queueSelection = new QueueSelection(ui.queueList, { 27 27 rowSelector: "tr", 28 - indexAttribute: DATA_ATTRS.INDEX, 28 + indexAttribute: "data-index", 29 29 selectedClass: CLASSES.SELECTED, 30 30 }); 31 31 32 32 // make only #queue main and #library div tabbable 33 - const mainEl = document.getElementById("queue"); 34 - const libraryEl = document.getElementById("library"); 33 + const mainEl = document.getElementById(DOM_IDS.QUEUE); 34 + const libraryEl = document.getElementById(DOM_IDS.LIBRARY); 35 35 36 36 if (mainEl) { 37 37 mainEl.tabIndex = 0; ··· 100 100 // loop toggle 101 101 ui.loopBtn.addEventListener("click", () => { 102 102 state.loop = !state.loop; 103 - ui.loopBtn.classList.toggle("active", state.loop); 103 + ui.loopBtn.classList.toggle(CLASSES.ACTIVE, state.loop); 104 104 }); 105 105 106 106 // sort queue ··· 111 111 112 112 // clear queue 113 113 ui.clearBtn.addEventListener("click", () => { 114 - (queueSelection?.getSelected()?.length > 0 115 - ? clearSelectedRows 116 - : clearQueue)(); 114 + queueSelection?.getSelected()?.length > 0 115 + ? clearSelectedRows() 116 + : clearQueue(); 117 117 }); 118 118 119 119 // queue table: row selection and action button handlers
+8 -10
src/js/input.js
··· 11 11 function lockTabOrder() { 12 12 document.querySelectorAll(INTERACTIVE_SELECTOR).forEach((el) => { 13 13 // skip queue and library which are always tabbable 14 - if (el.id === "queue" || el.id === "library") return; 14 + if (el.id === DOM_IDS.QUEUE || el.id === DOM_IDS.LIBRARY) return; 15 15 16 16 // skip if element is inside a modal 17 17 const modal = el.closest(".modal:not(.hidden)"); ··· 77 77 78 78 // get cached element by id, refresh if detached from DOM 79 79 function getCachedElement(type) { 80 - const id = type === "queue" ? "queue" : "library"; 80 + const id = type === "queue" ? DOM_IDS.QUEUE : DOM_IDS.LIBRARY; 81 81 if (!elementCache[type] || !document.body.contains(elementCache[type])) { 82 82 elementCache[type] = document.getElementById(id); 83 83 } ··· 139 139 showContextMenu: (selectedIndices) => { 140 140 // show context menu at the last selected row's position 141 141 const lastIdx = selectedIndices[selectedIndices.length - 1]; 142 - const row = ui.queueList.querySelector( 143 - `tr[${DATA_ATTRS.INDEX}="${lastIdx}"]`, 144 - ); 142 + const row = ui.queueList.querySelector(`tr[data-index="${lastIdx}"]`); 145 143 if (row) { 146 144 const rect = row.getBoundingClientRect(); 147 145 showQueueContextMenu(rect.left, rect.top + rect.height, selectedIndices); ··· 156 154 function setupKeyboardShortcuts() { 157 155 // setup keyboard help close button 158 156 const closeKeyboardHelpBtn = document.getElementById( 159 - "close-keyboard-help-btn", 157 + DOM_IDS.CLOSE_KEYBOARD_HELP_BTN, 160 158 ); 161 159 if (closeKeyboardHelpBtn) { 162 - closeKeyboardHelpBtn.onclick = () => hideModal("keyboard-help-modal"); 160 + closeKeyboardHelpBtn.onclick = () => hideModal(DOM_IDS.KEYBOARD_HELP_MODAL); 163 161 } 164 162 165 163 document.addEventListener("keydown", (e) => { ··· 169 167 // skip keyboard shortcuts if a modal is open (let modal handle it) 170 168 if ( 171 169 modalRegistry.size > 0 || 172 - (document.getElementById("context-menu") && 173 - document.body.contains(document.getElementById("context-menu"))) 170 + (document.getElementById(DOM_IDS.CONTEXT_MENU) && 171 + document.body.contains(document.getElementById(DOM_IDS.CONTEXT_MENU))) 174 172 ) 175 173 return; 176 174 ··· 415 413 if (!e.shiftKey) return; // Shift+R for toggle loop 416 414 e.preventDefault(); 417 415 state.loop = !state.loop; 418 - ui.loopBtn.classList.toggle("active", state.loop); 416 + ui.loopBtn.classList.toggle(CLASSES.ACTIVE, state.loop); 419 417 break; 420 418 } 421 419
+2 -2
src/js/library-selection.js
··· 39 39 focusItem(element) { 40 40 // set focus to specific item 41 41 if (this.currentFocusedItem) { 42 - this.currentFocusedItem.classList.remove("library-focused"); 42 + this.currentFocusedItem.classList.remove(CLASSES.FOCUSED); 43 43 } 44 44 if (element) { 45 - element.classList.add("library-focused"); 45 + element.classList.add(CLASSES.FOCUSED); 46 46 element.scrollIntoView({ block: "nearest" }); 47 47 this.currentFocusedItem = element; 48 48 }
+4 -4
src/js/library.js
··· 58 58 } 59 59 60 60 const addByType = { 61 - artist: (id) => addArtistToQueue(id), 62 - album: (id) => addAlbumToQueue(id), 63 - playlist: (id) => addPlaylistToQueue(id), 64 - song: (song) => addSongToQueue(song), 61 + artist: addArtistToQueue, 62 + album: addAlbumToQueue, 63 + playlist: addPlaylistToQueue, 64 + song: addSongToQueue, 65 65 }; 66 66 67 67 const addNextByType = {
+2 -2
src/js/modal.js
··· 24 24 clickHandler: null, 25 25 }; 26 26 27 - modalEl.classList.remove("hidden"); 27 + modalEl.classList.remove(CLASSES.HIDDEN); 28 28 cleanup.focusTrap = trapModalFocus(modalEl); 29 29 30 30 cleanup.escapeHandler = (e) => { ··· 71 71 document.removeEventListener("click", cleanup.clickHandler); 72 72 } 73 73 74 - modalEl.classList.add("hidden"); 74 + modalEl.classList.add(CLASSES.HIDDEN); 75 75 76 76 if (focusedBeforeModal && document.body.contains(focusedBeforeModal)) { 77 77 focusedBeforeModal.focus();
+1 -1
src/js/player.js
··· 116 116 const row = ui.queueList.querySelector( 117 117 `tr[data-index="${state.queueIndex}"]`, 118 118 ); 119 - if (row) row.classList.add("currently-playing"); 119 + if (row) row.classList.add(CLASSES.CURRENTLY_PLAYING); 120 120 } 121 121 } 122 122
+8 -4
src/js/queue-drag.js
··· 12 12 if ( 13 13 !row || 14 14 row.classList.contains(CLASSES.DRAGGING) || 15 - queueSelection.isSelected(parseInt(row.getAttribute(DATA_ATTRS.INDEX))) 15 + queueSelection.isSelected(parseInt(row.getAttribute("data-index"))) 16 16 ) 17 17 return; 18 18 19 19 queueSelection.clear(); 20 - queueSelection.select(parseInt(row.getAttribute(DATA_ATTRS.INDEX))); 20 + queueSelection.select(parseInt(row.getAttribute("data-index"))); 21 21 updateRowClass( 22 22 ui.queueList, 23 23 queueSelection.getSelected(), ··· 77 77 } 78 78 clearRowClasses(ui.queueList, CLASSES.DRAGGING); 79 79 80 - const draggedIdx = parseInt(row.getAttribute(DATA_ATTRS.INDEX)); 80 + const draggedIdx = parseInt(row.getAttribute("data-index")); 81 81 moveQueueItems( 82 82 state.queue, 83 83 queueSelection.getSelected(), 84 84 isDropBelowCenter(e, row) ? draggedIdx + 1 : draggedIdx, 85 - queueCallbacks, 85 + { 86 + onSelectionChange: (newIndices) => 87 + queueSelection?.setSelection(newIndices), 88 + onQueueChange: () => updateQueueDisplay(), 89 + }, 86 90 ); 87 91 88 92 // refocus after DOM render cycle completes
+19 -14
src/js/queue.js
··· 255 255 const container = document.querySelector("main"); 256 256 if (!container) return; 257 257 258 - virtualScroller = new VirtualScroller( 258 + virtualScroller = new QueueVirtualScroller( 259 259 container, 260 260 ui.queueList, 261 261 state.queue.length, ··· 273 273 // update queue display using virtual scroller 274 274 function updateQueueDisplay() { 275 275 ui.queueCount.textContent = state.queue.length; 276 - ( 277 - virtualScroller || (initVirtualScroller(), virtualScroller) 278 - )?.updateItemCount(state.queue.length); 276 + if (!virtualScroller) initVirtualScroller(); 277 + virtualScroller?.updateItemCount(state.queue.length); 279 278 } 280 279 281 280 // clear selected rows ··· 407 406 function createQueueRow(song, idx) { 408 407 const tr = document.createElement("tr"); 409 408 tr.draggable = true; 410 - tr.setAttribute(DATA_ATTRS.INDEX, idx); 409 + tr.setAttribute("data-index", idx); 411 410 tr.dataset.songId = song.id; 412 411 413 412 // batch all cells in fragment for efficient DOM insertion ··· 463 462 return tr; 464 463 } 465 464 466 - // callbacks for queue operations 467 - const queueCallbacks = { 468 - onSelectionChange: (newIndices) => queueSelection?.setSelection(newIndices), 469 - onQueueChange: () => updateQueueDisplay(), 470 - }; 471 - 472 465 // map button classes to queue action handlers 473 466 const QUEUE_BUTTON_HANDLERS = { 474 467 // play the selected track ··· 481 474 // insert selected track after current track 482 475 [CLASSES.QUEUE_PLAY_NEXT]: (idx) => { 483 476 const insertPos = state.queueIndex >= 0 ? state.queueIndex + 1 : 0; 484 - moveQueueItems(state.queue, [idx], insertPos, queueCallbacks); 477 + moveQueueItems(state.queue, [idx], insertPos, { 478 + onSelectionChange: (newIndices) => 479 + queueSelection?.setSelection(newIndices), 480 + onQueueChange: () => updateQueueDisplay(), 481 + }); 485 482 }, 486 483 // move up one position 487 484 [CLASSES.QUEUE_MOVE_UP]: (idx) => { 488 485 if (idx > 0) { 489 - moveQueueItems(state.queue, [idx], idx - 1, queueCallbacks); 486 + moveQueueItems(state.queue, [idx], idx - 1, { 487 + onSelectionChange: (newIndices) => 488 + queueSelection?.setSelection(newIndices), 489 + onQueueChange: () => updateQueueDisplay(), 490 + }); 490 491 } 491 492 }, 492 493 // move down one position 493 494 [CLASSES.QUEUE_MOVE_DOWN]: (idx) => { 494 495 if (idx < state.queue.length - 1) { 495 - moveQueueItems(state.queue, [idx], idx + 2, queueCallbacks); 496 + moveQueueItems(state.queue, [idx], idx + 2, { 497 + onSelectionChange: (newIndices) => 498 + queueSelection?.setSelection(newIndices), 499 + onQueueChange: () => updateQueueDisplay(), 500 + }); 496 501 } 497 502 }, 498 503 // clear from queue
+2 -2
src/js/settings.js
··· 8 8 "now-playing": "artNowPlaying", 9 9 }; 10 10 11 - // apply settings to CSS custom properties 11 + // apply settings 12 12 function applySettings() { 13 13 Object.entries(ART_SETTINGS).forEach(([name, key]) => { 14 14 const size = state.settings[key]; ··· 20 20 ui.coverArt.style.display = state.settings.artNowPlaying === 0 ? "none" : ""; 21 21 } 22 22 23 - // setup settings modal and controls 23 + // setup settings 24 24 function setupSettings() { 25 25 const modal = document.getElementById("settings-modal"); 26 26 const settingsBtn = document.getElementById("settings-btn");
+22 -26
src/js/virtual-scroll.js src/js/queue-virtualscroll.js
··· 1 1 // virtual scrolling for efficient rendering of massive queues 2 2 3 - const STRIPE_CLASS = "stripe"; 4 - 5 - class VirtualScroller { 3 + class QueueVirtualScroller { 6 4 // initialize virtual scroller with container, tbody, and row factory 7 5 constructor( 8 6 container, ··· 11 9 createRow, 12 10 { buffer = 32, onScroll } = {}, 13 11 ) { 14 - Object.assign(this, { 15 - container, 16 - tbody, 17 - itemCount, 18 - createRow, 19 - buffer, 20 - onScroll, 21 - }); 12 + this.container = container; 13 + this.tbody = tbody; 14 + this.itemCount = itemCount; 15 + this.createRow = createRow; 16 + this.buffer = buffer; 17 + this.onScroll = onScroll; 22 18 this.visibleStart = this.visibleEnd = 0; 23 19 this.rafId = null; 24 20 this.firstRender = true; 25 21 this.rowHeight = 0; 26 22 27 - this.handleScroll = () => this.scheduleRender(); 28 - this.handleResize = () => this.scheduleRender(); 23 + this.handleScheduleRender = () => this.scheduleRender(); 29 24 30 - this.container.addEventListener("scroll", this.handleScroll, { 25 + this.container.addEventListener("scroll", this.handleScheduleRender, { 31 26 passive: true, 32 27 }); 33 - window.addEventListener("resize", this.handleResize); 28 + window.addEventListener("resize", this.handleScheduleRender); 34 29 this.render(); 35 30 } 36 31 ··· 80 75 this.visibleStart = start; 81 76 this.visibleEnd = end; 82 77 83 - const rows = []; 78 + const frag = document.createDocumentFragment(); 84 79 85 - // top spacer for rows before visible range 80 + // spacer for hidden top rows 86 81 if (start > 0) { 87 82 const tr = document.createElement("tr"); 88 83 tr.style.height = `${start * this.rowHeight}px`; 89 - rows.push(tr); 84 + frag.appendChild(tr); 90 85 } 91 86 92 - // visible rows with alternating stripe class 87 + // visible rows with stripe pattern 93 88 for (let i = start; i < end; i++) { 94 89 const row = this.createRow(i); 95 - if (i % 2 === 1) row.classList.add(STRIPE_CLASS); 96 - rows.push(row); 90 + if (i % 2 === 1) row.classList.add(CLASSES.STRIPE); 91 + frag.appendChild(row); 97 92 } 98 93 99 - // bottom spacer for rows after visible range 94 + // spacer for hidden bottom rows 100 95 if (end < this.itemCount) { 101 96 const tr = document.createElement("tr"); 102 97 tr.style.height = `${(this.itemCount - end) * this.rowHeight}px`; 103 - rows.push(tr); 98 + frag.appendChild(tr); 104 99 } 105 100 106 - this.tbody.replaceChildren(...rows); 101 + this.tbody.replaceChildren(); 102 + this.tbody.appendChild(frag); 107 103 this.onScroll?.(); 108 104 } 109 105 ··· 116 112 // clean up event listeners and cancel pending renders 117 113 destroy() { 118 114 cancelAnimationFrame(this.rafId); 119 - this.container.removeEventListener("scroll", this.handleScroll); 120 - window.removeEventListener("resize", this.handleResize); 115 + this.container.removeEventListener("scroll", this.handleScheduleRender); 116 + window.removeEventListener("resize", this.handleScheduleRender); 121 117 } 122 118 }