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

refactor: rewrite virtual scroller

i stuck with spacers because vue-virtual-scroll's method of using minheight didnt really work well for tables. but this is less buggy and far cleaner

+170 -224
-5
src/css/components.css
··· 178 178 margin-top: 0.5rem; 179 179 } 180 180 181 - /* hint optimization for virtual scroll updates */ 182 - tbody { 183 - will-change: contents; 184 - } 185 - 186 181 th, 187 182 td { 188 183 text-align: left;
-1
src/css/layout.css
··· 45 45 grid-area: main; 46 46 overflow-y: auto; 47 47 padding-inline: 1rem; 48 - -webkit-overflow-scrolling: touch; 49 48 } 50 49 51 50 /* action buttons and version info */
+1
src/index.html
··· 213 213 <script src="js/state.js"></script> 214 214 <script src="js/settings.js"></script> 215 215 <script src="js/image-cache.js"></script> 216 + <script src="js/virtual-scroll.js"></script> 216 217 <script src="js/queue.js"></script> 217 218 <script src="js/library.js"></script> 218 219 <script src="js/ui.js"></script>
+3 -4
src/js/draggable.js
··· 1 1 // setup drag and drop for queue reordering 2 2 function setupDragAndDrop() { 3 3 const clearDragOver = () => 4 - clearRowClasses(ui.queueList, "tr:not(.virtual-spacer)", [ 4 + clearRowClasses(ui.queueList, "tr", [ 5 5 CLASSES.DRAG_OVER_ABOVE, 6 6 CLASSES.DRAG_OVER_BELOW, 7 7 CLASSES.DRAGGING, 8 8 ]); 9 9 const isDraggable = (row) => 10 10 row && 11 - !row.classList.contains(CLASSES.DRAGGING) && 12 - !row.classList.contains("virtual-spacer"); 11 + !row.classList.contains(CLASSES.DRAGGING); 13 12 const isDropBelowCenter = (e, row) => 14 13 e.clientY - row.getBoundingClientRect().top > row.offsetHeight / 2; 15 14 ··· 32 31 const row = getClosestRow(e.target); 33 32 if (!isDraggable(row)) return; 34 33 35 - clearRowClasses(ui.queueList, "tr:not(.virtual-spacer)", [ 34 + clearRowClasses(ui.queueList, "tr", [ 36 35 CLASSES.DRAG_OVER_ABOVE, 37 36 CLASSES.DRAG_OVER_BELOW, 38 37 ]);
+1 -3
src/js/events.js
··· 183 183 selectedClass: CLASSES.SELECTED, 184 184 }); 185 185 186 - // initialize virtual scrolling for queue 187 - initVirtualScroll(); 188 - 189 186 // setup settings modal 190 187 setupSettings(); 191 188 ··· 295 292 // setup event handlers 296 293 setupDragAndDrop(); 297 294 setupQueueContextMenu(); 295 + initVirtualScroller(); 298 296 setupKeyboardShortcuts(); 299 297 setupMediaSessionHandlers(); 300 298
+59 -209
src/js/queue.js
··· 1 - // some of this was taken from various sources on stackoverflow and some of it was also ai-assisted, it's likely rough around the edges in places and could use improvements 2 - // too complex for my brain 🐈 3 - 4 1 // persist queue and current index to localStorage 5 2 function saveQueue() { 6 3 try { ··· 30 27 } 31 28 } 32 29 } 33 - } 34 - 35 - // update queue display and save state 36 - function updateQueue() { 37 - updateQueueDisplay(); 38 - saveQueue(); 39 30 } 40 31 41 32 // restore queue from localStorage ··· 97 88 const addSongToQueue = (song) => 98 89 addToQueue(() => Promise.resolve(song), SONG_EXTRACTORS.song); 99 90 100 - // virtual scrolling state with dynamic row height measurement 101 - const VIRTUAL_SCROLL = { 102 - rowHeight: null, 103 - buffer: 16, 104 - container: null, 105 - visibleStart: 0, 106 - visibleEnd: 0, 107 - wrapper: null, 108 - scrollScheduled: false, 109 - measurementScheduled: false, 110 - SPACER_CLASS: "virtual-spacer", 111 - }; 112 - 113 - function resetVirtualScrollMeasurements() { 114 - VIRTUAL_SCROLL.rowHeight = null; 91 + // update queue display and save state 92 + function updateQueue() { 93 + updateQueueDisplay(); 94 + saveQueue(); 115 95 } 116 96 117 - // setup scroll and resize listeners 118 - function initVirtualScroll() { 119 - if (VIRTUAL_SCROLL.container) return; 120 - 121 - VIRTUAL_SCROLL.container = document.querySelector("main"); 122 - if (!VIRTUAL_SCROLL.container) return; 123 - 124 - VIRTUAL_SCROLL.wrapper = ui.queueList; 125 - 126 - VIRTUAL_SCROLL.container.addEventListener("scroll", handleVirtualScroll, { 127 - passive: true, 128 - }); 129 - window.addEventListener("resize", () => { 130 - handleVirtualScroll(false); 131 - }); 132 - } 133 - 134 - // calculate which rows are visible given current scroll position 135 - function calculateVisibleRange(scrollTop, viewportHeight) { 136 - if (!VIRTUAL_SCROLL.rowHeight) { 137 - return { start: 0, end: state.queue.length }; 138 - } 139 - 140 - const rowHeight = VIRTUAL_SCROLL.rowHeight; 141 - return { 142 - start: Math.max( 143 - 0, 144 - Math.floor(scrollTop / rowHeight) - VIRTUAL_SCROLL.buffer, 145 - ), 146 - end: Math.min( 147 - state.queue.length, 148 - Math.ceil((scrollTop + viewportHeight) / rowHeight) + 149 - VIRTUAL_SCROLL.buffer, 150 - ), 151 - }; 152 - } 153 - 154 - // update visible rows on scroll 155 - function handleVirtualScroll(forceRender = false) { 156 - if (!VIRTUAL_SCROLL.container) return; 157 - 158 - if (VIRTUAL_SCROLL.scrollScheduled && !forceRender) return; 159 - VIRTUAL_SCROLL.scrollScheduled = true; 97 + let virtualScroller = null; 160 98 161 - requestAnimationFrame(() => { 162 - const range = calculateVisibleRange( 163 - VIRTUAL_SCROLL.container.scrollTop, 164 - VIRTUAL_SCROLL.container.clientHeight, 165 - ); 166 - 167 - if ( 168 - forceRender || 169 - range.start !== VIRTUAL_SCROLL.visibleStart || 170 - range.end !== VIRTUAL_SCROLL.visibleEnd 171 - ) { 172 - VIRTUAL_SCROLL.visibleStart = range.start; 173 - VIRTUAL_SCROLL.visibleEnd = range.end; 174 - renderVirtualRows(forceRender); 175 - highlightCurrentTrack(); 99 + // initialize virtual scroller for queue table 100 + function initVirtualScroller() { 101 + if (virtualScroller) return; 102 + const container = document.querySelector('main'); 103 + if (!container) return; 104 + 105 + virtualScroller = new VirtualScroller( 106 + container, 107 + ui.queueList, 108 + state.queue.length, 109 + (idx) => createQueueRow(state.queue[idx], idx), 110 + { 111 + onScroll: () => { 112 + updateSelectionUI(); 113 + highlightCurrentTrack(); 114 + }, 176 115 } 177 - VIRTUAL_SCROLL.scrollScheduled = false; 178 - }); 179 - } 180 - 181 - // render visible rows with spacers 182 - function renderVirtualRows(forceRender = true) { 183 - const { visibleStart, visibleEnd, wrapper } = VIRTUAL_SCROLL; 184 - const rowHeight = VIRTUAL_SCROLL.rowHeight || 0; 185 - 186 - // spacer rows for unrendered content 187 - const topSpacer = document.createElement("tr"); 188 - topSpacer.style.height = `${visibleStart * rowHeight}px`; 189 - topSpacer.classList.add(VIRTUAL_SCROLL.SPACER_CLASS); 190 - 191 - const bottomSpacer = document.createElement("tr"); 192 - const remainingRows = state.queue.length - visibleEnd; 193 - bottomSpacer.style.height = `${remainingRows * rowHeight}px`; 194 - bottomSpacer.classList.add(VIRTUAL_SCROLL.SPACER_CLASS); 195 - 196 - // collect rows to render 197 - const newRows = [topSpacer]; 198 - for (let idx = visibleStart; idx < visibleEnd; idx++) { 199 - newRows.push(createQueueRow(state.queue[idx], idx)); 200 - } 201 - newRows.push(bottomSpacer); 202 - 203 - wrapper.replaceChildren(...newRows); 204 - updateSelectionUI(); 205 - 206 - // schedule measurement after structural changes 207 - if (forceRender && !VIRTUAL_SCROLL.measurementScheduled) { 208 - VIRTUAL_SCROLL.measurementScheduled = true; 209 - requestAnimationFrame(() => { 210 - if (visibleStart < visibleEnd) { 211 - const firstRow = wrapper.querySelector( 212 - `tr:not(.${VIRTUAL_SCROLL.SPACER_CLASS})`, 213 - ); 214 - if (firstRow?.offsetHeight > 0) { 215 - const measured = firstRow.offsetHeight; 216 - if (VIRTUAL_SCROLL.rowHeight !== measured) { 217 - VIRTUAL_SCROLL.rowHeight = measured; 218 - const newRange = calculateVisibleRange( 219 - VIRTUAL_SCROLL.container.scrollTop, 220 - VIRTUAL_SCROLL.container.clientHeight, 221 - ); 222 - if ( 223 - newRange.start !== VIRTUAL_SCROLL.visibleStart || 224 - newRange.end !== VIRTUAL_SCROLL.visibleEnd 225 - ) { 226 - const oldRowCount = 227 - VIRTUAL_SCROLL.visibleEnd - VIRTUAL_SCROLL.visibleStart; 228 - const newRowCount = newRange.end - newRange.start; 229 - const rowCountDiff = Math.abs(newRowCount - oldRowCount); 230 - // only re-render if visible row count changed significantly (more than buffer) 231 - if (rowCountDiff > VIRTUAL_SCROLL.buffer / 2) { 232 - VIRTUAL_SCROLL.visibleStart = newRange.start; 233 - VIRTUAL_SCROLL.visibleEnd = newRange.end; 234 - renderVirtualRows(false); 235 - } 236 - } 237 - } 238 - } 239 - } 240 - VIRTUAL_SCROLL.measurementScheduled = false; 241 - highlightCurrentTrack(); 242 - }); 243 - } else { 244 - highlightCurrentTrack(); 245 - } 116 + ); 246 117 } 247 118 248 - // update queue display and re-render with fresh visible range 119 + // update queue display using virtual scroller 249 120 function updateQueueDisplay() { 250 121 ui.queueCount.textContent = state.queue.length; 251 - handleVirtualScroll(true); 122 + (virtualScroller || (initVirtualScroller(), virtualScroller))?.updateItemCount(state.queue.length); 252 123 } 253 124 254 125 // clear selected rows ··· 258 129 259 130 const toRemoveSet = new Set(toRemove); 260 131 const wasCurrentTrackDeleted = toRemoveSet.has(state.queueIndex); 132 + const deletionsBefore = toRemove.filter(idx => idx < state.queueIndex).length; 261 133 262 134 state.queue = state.queue.filter((_, idx) => !toRemoveSet.has(idx)); 263 135 264 136 if (!wasCurrentTrackDeleted && state.queueIndex >= 0) { 265 - // adjust index for deleted rows before current track 266 - state.queueIndex -= toRemove.filter((i) => i < state.queueIndex).length; 137 + state.queueIndex -= deletionsBefore; 267 138 } else if (wasCurrentTrackDeleted) { 268 - // find safe replacement if current track was deleted 269 139 state.queueIndex = Math.min(state.queueIndex, state.queue.length - 1); 270 140 if (state.queueIndex >= 0) { 271 141 playTrack(state.queue[state.queueIndex]); ··· 281 151 282 152 // randomly reorder queue items while preserving current track position 283 153 function shuffleQueue() { 284 - const currentTrack = 285 - state.queueIndex >= 0 ? state.queue[state.queueIndex] : null; 154 + const currentTrack = state.queueIndex >= 0 ? state.queue[state.queueIndex] : null; 286 155 287 156 for (let i = state.queue.length - 1; i > 0; i--) { 288 157 const j = Math.floor(Math.random() * (i + 1)); ··· 305 174 updateQueueDisplay(); 306 175 } 307 176 308 - // button config for queue row actions, cached to avoid repeated creation 177 + // buttons for queue row actions 309 178 const ROW_BUTTON_CONFIG = [ 310 179 { 311 180 className: CLASSES.QUEUE_ACTION, ··· 339 208 }, 340 209 ]; 341 210 342 - // set cover art cell content 343 - function setQueueCoverArt(cell, song) { 344 - cell.replaceChildren(); 345 - const songArtId = shouldShowArt("artSong", song.coverArt); 346 - const size = state.settings.artSong; 347 - 348 - if (size > 0) { 349 - // create wrapper to reserve space for image before it loads 350 - const wrapper = document.createElement("div"); 351 - wrapper.style.width = `${size}px`; 352 - wrapper.style.height = `${size}px`; 353 - wrapper.style.flexShrink = "0"; 354 - wrapper.style.overflow = "hidden"; 355 - 356 - const img = document.createElement("img"); 357 - img.alt = "cover"; 358 - img.className = CLASSES.QUEUE_COVER; 359 - img.style.width = "100%"; 360 - img.style.height = "100%"; 361 - 362 - wrapper.appendChild(img); 363 - cell.appendChild(wrapper); 364 - 365 - // load actual image if art should be shown 366 - if (songArtId) { 367 - loadCachedImage(img, songArtId, "artSong"); 368 - } 369 - } 370 - } 371 - 372 211 // create table row element for a song in queue 373 212 function createQueueRow(song, idx) { 374 213 const tr = document.createElement("tr"); 375 214 tr.draggable = true; 376 215 tr.setAttribute(DATA_ATTRS.INDEX, idx); 377 - tr.classList.toggle("stripe", idx % 2 === 1); 378 - tr.dataset.songId = song.id; // for event handling 216 + tr.dataset.songId = song.id; 379 217 218 + // batch all cells in fragment for efficient DOM insertion 219 + const cells = document.createDocumentFragment(); 220 + 380 221 // cover art cell 381 222 const coverCell = document.createElement("td"); 382 - setQueueCoverArt(coverCell, song); 383 - tr.appendChild(coverCell); 223 + if (song.coverArt && state.settings.artSong > 0) { 224 + const img = document.createElement("img"); 225 + img.className = "queue-cover"; 226 + coverCell.appendChild(img); 227 + loadCachedImage(img, song.coverArt, "artSong"); 228 + } 229 + cells.appendChild(coverCell); 384 230 385 231 // text cells 386 - const createTextCell = (content) => { 387 - const cell = document.createElement("td"); 388 - cell.textContent = content; 389 - tr.appendChild(cell); 390 - }; 391 - createTextCell(song.title); 392 - createTextCell(song.artist || ""); 393 - createTextCell(song.album || ""); 394 - createTextCell(formatDuration(song.duration)); 232 + const titleCell = document.createElement("td"); 233 + titleCell.textContent = song.title; 234 + cells.appendChild(titleCell); 235 + 236 + const artistCell = document.createElement("td"); 237 + artistCell.textContent = song.artist || ""; 238 + cells.appendChild(artistCell); 239 + 240 + const albumCell = document.createElement("td"); 241 + albumCell.textContent = song.album || ""; 242 + cells.appendChild(albumCell); 243 + 244 + const durationCell = document.createElement("td"); 245 + durationCell.textContent = formatDuration(song.duration); 246 + cells.appendChild(durationCell); 395 247 396 248 // action buttons cell 397 249 const actionsCell = document.createElement("td"); 398 - const btnFragment = document.createDocumentFragment(); 399 - 250 + const isFavorited = state.favorites.has(song.id); 400 251 ROW_BUTTON_CONFIG.forEach(({ className, label, icon }) => { 401 252 const btn = createIconButton(className, label, icon, label); 402 - if (className === CLASSES.QUEUE_FAVORITE && state.favorites.has(song.id)) { 253 + if (className === CLASSES.QUEUE_FAVORITE && isFavorited) { 403 254 btn.classList.add(CLASSES.FAVORITED); 404 255 } 405 - btnFragment.appendChild(btn); 256 + actionsCell.appendChild(btn); 406 257 }); 407 - 408 - actionsCell.appendChild(btnFragment); 409 - tr.appendChild(actionsCell); 258 + cells.appendChild(actionsCell); 410 259 260 + tr.appendChild(cells); 411 261 return tr; 412 262 }
+1 -2
src/js/settings.js
··· 92 92 93 93 // trigger refresh of queue and library when art sizes change 94 94 function triggerQueueRefresh() { 95 - resetVirtualScrollMeasurements(); 96 - handleVirtualScroll(true); 95 + updateQueueDisplay(); 97 96 renderLibraryTree(); 98 97 renderPlaylistsTree(); 99 98 }
+105
src/js/virtual-scroll.js
··· 1 + const STRIPE_CLASS = 'stripe'; 2 + 3 + class VirtualScroller { 4 + // initialize virtual scroller with container, tbody, and row factory 5 + constructor(container, tbody, itemCount, createRow, { buffer = 8, onScroll } = {}) { 6 + Object.assign(this, { container, tbody, itemCount, createRow, buffer, onScroll }); 7 + this.visibleStart = this.visibleEnd = 0; 8 + this.rafId = null; 9 + this.resizeTimeout = null; 10 + this.firstRender = true; 11 + this.rowHeight = 0; 12 + 13 + this.handleScroll = () => this.scheduleRender(); 14 + this.handleResize = () => { 15 + clearTimeout(this.resizeTimeout); 16 + this.resizeTimeout = setTimeout(() => this.render(), 150); 17 + }; 18 + 19 + this.container.addEventListener('scroll', this.handleScroll, { passive: true }); 20 + window.addEventListener('resize', this.handleResize); 21 + this.render(); 22 + } 23 + 24 + // batch scroll and resize events into a single render call via requestAnimationFrame 25 + scheduleRender() { 26 + if (this.rafId) return; 27 + this.rafId = requestAnimationFrame(() => { 28 + this.rafId = null; 29 + this.render(); 30 + }); 31 + } 32 + 33 + // render visible rows with spacer rows for scroll height 34 + render(forceRedraw = false) { 35 + // measure row height on first render or when settings change 36 + if (this.firstRender || forceRedraw) { 37 + if (this.itemCount > 0) { 38 + const sample = this.createRow(0); 39 + this.tbody.appendChild(sample); 40 + this.rowHeight = sample.offsetHeight; 41 + sample.remove(); 42 + } 43 + this.firstRender = false; 44 + } 45 + 46 + if (!this.itemCount) { 47 + this.tbody.replaceChildren(); 48 + return; 49 + } 50 + 51 + // calculate visible range based on scroll position 52 + const scrollTop = this.container.scrollTop; 53 + const start = Math.max(0, Math.floor(scrollTop / this.rowHeight) - this.buffer); 54 + const end = Math.min( 55 + this.itemCount, 56 + Math.ceil((scrollTop + this.container.clientHeight) / this.rowHeight) + this.buffer 57 + ); 58 + 59 + // skip render if visible range hasn't changed 60 + if (!forceRedraw && start === this.visibleStart && end === this.visibleEnd) return; 61 + 62 + this.visibleStart = start; 63 + this.visibleEnd = end; 64 + 65 + const rows = []; 66 + 67 + // top spacer for rows before visible range 68 + if (start > 0) { 69 + const tr = document.createElement('tr'); 70 + tr.style.height = `${start * this.rowHeight}px`; 71 + rows.push(tr); 72 + } 73 + 74 + // visible rows with alternating stripe class 75 + for (let i = start; i < end; i++) { 76 + const row = this.createRow(i); 77 + if (i % 2 === 1) row.classList.add(STRIPE_CLASS); 78 + rows.push(row); 79 + } 80 + 81 + // bottom spacer for rows after visible range 82 + if (end < this.itemCount) { 83 + const tr = document.createElement('tr'); 84 + tr.style.height = `${(this.itemCount - end) * this.rowHeight}px`; 85 + rows.push(tr); 86 + } 87 + 88 + this.tbody.replaceChildren(...rows); 89 + this.onScroll?.(); 90 + } 91 + 92 + // update item count and re-render with fresh data 93 + updateItemCount(itemCount) { 94 + this.itemCount = itemCount; 95 + this.render(true); 96 + } 97 + 98 + // clean up event listeners and cancel pending renders 99 + destroy() { 100 + cancelAnimationFrame(this.rafId); 101 + clearTimeout(this.resizeTimeout); 102 + this.container.removeEventListener('scroll', this.handleScroll); 103 + window.removeEventListener('resize', this.handleResize); 104 + } 105 + }