a simple web player for subsonic
0
fork

Configure Feed

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

refactor: improve virtual scrolling implementation

should be far more performant and less buggy now hopefully

closes #8

+116 -100
+3
src/js/events.js
··· 75 75 selectedClass: CLASSES.SELECTED, 76 76 }); 77 77 78 + // initialize virtual scrolling for queue 79 + initVirtualScroll(); 80 + 78 81 // setup settings modal 79 82 setupSettings(); 80 83
+8 -12
src/js/image-cache.js
··· 187 187 188 188 // check if art is enabled in settings 189 189 function shouldShowArt(artType, artId) { 190 - return state.settings[artType] > 0 ? artId : null; 190 + const setting = state.settings[artType]; 191 + return setting > 0 ? artId : null; 191 192 } 192 193 193 194 // load image with appropriate device density ··· 199 200 ? [url1x, state.api.getCoverArtUrl(artId, size1x * 2)] 200 201 : [url1x]; 201 202 202 - const setImage = (srcs) => { 203 - img.src = srcs[0]; 204 - if (srcs.length > 1) img.srcset = `${srcs[0]} 1x, ${srcs[1]} 2x`; 205 - }; 206 - 207 203 // sync cache check 208 204 const cached = imageCache.cachedUrls(urls); 209 205 if (cached) { 210 - setImage(cached); 206 + img.src = cached[0]; 207 + if (cached.length > 1) img.srcset = `${cached[0]} 1x, ${cached[1]} 2x`; 211 208 return; 212 209 } 213 210 214 211 // async lazy load on viewport entry 215 - const loadImages = async () => { 212 + imageCache.observeImage(img, async () => { 216 213 try { 217 214 const srcs = await Promise.all(urls.map((url) => imageCache.get(url))); 218 - setImage(srcs); 215 + img.src = srcs[0]; 216 + if (srcs.length > 1) img.srcset = `${srcs[0]} 1x, ${srcs[1]} 2x`; 219 217 } catch (err) { 220 218 // network error, img shows placeholder 221 219 } 222 - }; 223 - 224 - imageCache.observeImage(img, loadImages); 220 + }); 225 221 }
+105 -88
src/js/queue.js
··· 32 32 } 33 33 } 34 34 35 - // update queue display, highlight current track, and save state 36 - function updateQueue(updateDisplay = true) { 37 - if (updateDisplay) updateQueueDisplay(); 35 + // update queue display and save state 36 + function updateQueue() { 37 + updateQueueDisplay(); 38 38 saveQueue(); 39 39 } 40 40 ··· 76 76 song: (s) => [s], 77 77 }; 78 78 79 - // add songs to queue (with optional insertNext mode) 79 + // add songs to queue 80 80 async function addToQueue(fetcher, songExtractor, insertNext = false) { 81 81 const songs = await songExtractor(await fetcher()); 82 82 if (insertNext) { ··· 100 100 // virtual scrolling state with dynamic row height measurement 101 101 const VIRTUAL_SCROLL = { 102 102 rowHeight: null, 103 - buffer: 4, 103 + buffer: 16, 104 104 container: null, 105 105 visibleStart: 0, 106 106 visibleEnd: 0, 107 - initialized: false, 108 - measured: false, 109 - rowCache: new Map(), 107 + wrapper: null, 110 108 scrollScheduled: false, 109 + measurementScheduled: false, 111 110 SPACER_CLASS: "virtual-spacer", 112 111 }; 113 112 114 - // reset cached measurements when layout changes 115 113 function resetVirtualScrollMeasurements() { 116 114 VIRTUAL_SCROLL.rowHeight = null; 117 - VIRTUAL_SCROLL.measured = false; 118 - VIRTUAL_SCROLL.rowCache.clear(); 119 115 } 120 116 121 117 // setup scroll and resize listeners 122 118 function initVirtualScroll() { 123 - if (VIRTUAL_SCROLL.initialized) return; 124 - VIRTUAL_SCROLL.initialized = true; 119 + if (VIRTUAL_SCROLL.container) return; 125 120 126 121 VIRTUAL_SCROLL.container = document.querySelector("main"); 127 122 if (!VIRTUAL_SCROLL.container) return; 128 123 124 + VIRTUAL_SCROLL.wrapper = ui.queueList; 125 + 129 126 VIRTUAL_SCROLL.container.addEventListener("scroll", handleVirtualScroll, { 130 127 passive: true, 131 128 }); 132 129 window.addEventListener("resize", () => { 133 - resetVirtualScrollMeasurements(); 134 - handleVirtualScroll(true); 130 + handleVirtualScroll(false); 135 131 }); 136 132 } 137 133 ··· 140 136 if (!VIRTUAL_SCROLL.rowHeight) { 141 137 return { start: 0, end: state.queue.length }; 142 138 } 139 + 140 + const rowHeight = VIRTUAL_SCROLL.rowHeight; 143 141 return { 144 142 start: Math.max( 145 143 0, 146 - Math.floor(scrollTop / VIRTUAL_SCROLL.rowHeight) - VIRTUAL_SCROLL.buffer, 144 + Math.floor(scrollTop / rowHeight) - VIRTUAL_SCROLL.buffer, 147 145 ), 148 146 end: Math.min( 149 147 state.queue.length, 150 - Math.ceil((scrollTop + viewportHeight) / VIRTUAL_SCROLL.rowHeight) + 148 + Math.ceil((scrollTop + viewportHeight) / rowHeight) + 151 149 VIRTUAL_SCROLL.buffer, 152 150 ), 153 151 }; 154 152 } 155 153 156 - // update visible rows on scroll (throttled with requestAnimationFrame) 154 + // update visible rows on scroll 157 155 function handleVirtualScroll(forceRender = false) { 158 156 if (!VIRTUAL_SCROLL.container) return; 159 157 ··· 180 178 }); 181 179 } 182 180 183 - // render visible rows and spacers, measure actual row height 181 + // render visible rows with spacers 184 182 function renderVirtualRows(forceRender = true) { 185 - const { visibleStart, visibleEnd } = VIRTUAL_SCROLL; 183 + const { visibleStart, visibleEnd, wrapper } = VIRTUAL_SCROLL; 184 + const rowHeight = VIRTUAL_SCROLL.rowHeight || 0; 186 185 187 - for (const [idx, row] of VIRTUAL_SCROLL.rowCache.entries()) { 188 - if (idx < visibleStart || idx >= visibleEnd) { 189 - row.remove(); 190 - VIRTUAL_SCROLL.rowCache.delete(idx); 191 - } 192 - } 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); 193 190 194 - const newContent = []; 195 - 196 - // create spacer row (invisible, used for scroll height) 197 - const createSpacer = (rowCount) => { 198 - const spacer = document.createElement("tr"); 199 - spacer.style.height = `${rowCount * VIRTUAL_SCROLL.rowHeight}px`; 200 - spacer.classList.add(VIRTUAL_SCROLL.SPACER_CLASS); 201 - return spacer; 202 - }; 203 - 204 - if (visibleStart > 0) newContent.push(createSpacer(visibleStart)); 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); 205 195 196 + // collect rows to render 197 + const newRows = [topSpacer]; 206 198 for (let idx = visibleStart; idx < visibleEnd; idx++) { 207 - let row = VIRTUAL_SCROLL.rowCache.get(idx); 208 - if (!row) { 209 - row = createQueueRow(state.queue[idx], idx); 210 - VIRTUAL_SCROLL.rowCache.set(idx, row); 211 - } 212 - newContent.push(row); 199 + newRows.push(createQueueRow(state.queue[idx], idx)); 213 200 } 214 - 215 - const remainingRows = state.queue.length - visibleEnd; 216 - if (remainingRows > 0) newContent.push(createSpacer(remainingRows)); 201 + newRows.push(bottomSpacer); 217 202 218 - ui.queueList.replaceChildren(...newContent); 203 + wrapper.replaceChildren(...newRows); 219 204 updateSelectionUI(); 220 205 221 - if (forceRender && !VIRTUAL_SCROLL.measured && visibleStart < visibleEnd) { 206 + // schedule measurement after structural changes 207 + if (forceRender && !VIRTUAL_SCROLL.measurementScheduled) { 208 + VIRTUAL_SCROLL.measurementScheduled = true; 222 209 requestAnimationFrame(() => { 223 - const firstRow = ui.queueList.querySelector( 224 - `tr:not(.${VIRTUAL_SCROLL.SPACER_CLASS})`, 225 - ); 226 - if (firstRow?.offsetHeight > 0) { 227 - const measured = firstRow.offsetHeight; 228 - if (measured !== VIRTUAL_SCROLL.rowHeight) { 229 - VIRTUAL_SCROLL.rowHeight = measured; 230 - const newRange = calculateVisibleRange( 231 - VIRTUAL_SCROLL.container.scrollTop, 232 - VIRTUAL_SCROLL.container.clientHeight, 233 - ); 234 - if ( 235 - newRange.start !== VIRTUAL_SCROLL.visibleStart || 236 - newRange.end !== VIRTUAL_SCROLL.visibleEnd 237 - ) { 238 - VIRTUAL_SCROLL.visibleStart = newRange.start; 239 - VIRTUAL_SCROLL.visibleEnd = newRange.end; 240 - renderVirtualRows(false); 241 - highlightCurrentTrack(); 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 + } 242 237 } 243 238 } 244 239 } 245 - VIRTUAL_SCROLL.measured = true; 240 + VIRTUAL_SCROLL.measurementScheduled = false; 241 + highlightCurrentTrack(); 246 242 }); 243 + } else { 244 + highlightCurrentTrack(); 247 245 } 248 246 } 249 247 250 - // clear cache and reset height measurement when queue changes 248 + // update queue display and re-render with fresh visible range 251 249 function updateQueueDisplay() { 252 250 ui.queueCount.textContent = state.queue.length; 253 - resetVirtualScrollMeasurements(); 254 - initVirtualScroll(); 255 251 handleVirtualScroll(true); 256 252 } 257 253 ··· 280 276 281 277 clearSelection(); 282 278 updateQueue(); 283 - // ensure highlight applies with correct adjusted index 284 279 highlightCurrentTrack(); 285 280 } 286 281 ··· 298 293 state.queueIndex = state.queue.indexOf(currentTrack); 299 294 } 300 295 301 - updateQueueDisplay(); 296 + updateQueue(); 302 297 } 303 298 304 299 // remove all items from queue ··· 344 339 }, 345 340 ]; 346 341 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 + 347 372 // create table row element for a song in queue 348 373 function createQueueRow(song, idx) { 349 374 const tr = document.createElement("tr"); 350 375 tr.draggable = true; 351 376 tr.setAttribute(DATA_ATTRS.INDEX, idx); 352 377 tr.classList.toggle("stripe", idx % 2 === 1); 353 - 354 - // helper to create and append text cell 355 - const createTextCell = (content) => { 356 - const cell = document.createElement("td"); 357 - cell.textContent = content; 358 - tr.appendChild(cell); 359 - }; 378 + tr.dataset.songId = song.id; // for event handling 360 379 361 380 // cover art cell 362 381 const coverCell = document.createElement("td"); 363 - const songArtId = shouldShowArt("artSong", song.coverArt); 364 - if (songArtId) { 365 - const img = document.createElement("img"); 366 - img.alt = "cover"; 367 - img.className = CLASSES.QUEUE_COVER; 368 - loadCachedImage(img, songArtId, "artSong"); 369 - coverCell.appendChild(img); 370 - } 382 + setQueueCoverArt(coverCell, song); 371 383 tr.appendChild(coverCell); 372 384 373 385 // text cells 386 + const createTextCell = (content) => { 387 + const cell = document.createElement("td"); 388 + cell.textContent = content; 389 + tr.appendChild(cell); 390 + }; 374 391 createTextCell(song.title); 375 392 createTextCell(song.artist || ""); 376 393 createTextCell(song.album || "");