Something went wrong. Try again.
a simple but full-featured web player for OpenSubsonic music servers (Navidrome, Gonic, Ampache, etc) devins.page/tinysub
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119// virtual scrolling for efficient rendering of massive queues
class QueueVirtualScroller { // initialize virtual scroller with container, tbody, and row factory constructor( container, tbody, itemCount, createRow, { buffer = 32, onScroll } = {}, ) { this.container = container; this.tbody = tbody; this.itemCount = itemCount; this.createRow = createRow; this.buffer = buffer; this.onScroll = onScroll; this.visibleStart = this.visibleEnd = 0; this.rafId = null; this.firstRender = true; this.rowHeight = 0;
this.handleScheduleRender = () => this.scheduleRender();
this.container.addEventListener("scroll", this.handleScheduleRender, { passive: true, }); window.addEventListener("resize", this.handleScheduleRender); this.render(); }
// batch scroll and resize events into a single render call via requestAnimationFrame scheduleRender() { if (this.rafId) return; this.rafId = requestAnimationFrame(() => { this.rafId = null; this.render(); }); }
// render visible rows with spacer rows for scroll height render(forceRedraw = false) { // measure row height on first render or when settings change if (this.firstRender || forceRedraw) { if (this.itemCount > 0) { const sample = this.createRow(0); this.tbody.appendChild(sample); this.rowHeight = sample.offsetHeight; sample.remove(); } this.firstRender = false; }
if (!this.itemCount) { this.tbody.replaceChildren(); return; }
// calculate visible range based on scroll position const scrollTop = this.container.scrollTop; const start = Math.max( 0, Math.floor(scrollTop / this.rowHeight) - this.buffer, ); const end = Math.min( this.itemCount, Math.ceil((scrollTop + this.container.clientHeight) / this.rowHeight) + this.buffer, );
// skip render if visible range hasn't changed if (!forceRedraw && start === this.visibleStart && end === this.visibleEnd) return;
this.visibleStart = start; this.visibleEnd = end;
const frag = document.createDocumentFragment();
// spacer for hidden top rows if (start > 0) { const tr = document.createElement("tr"); tr.style.height = `${start * this.rowHeight}px`; frag.appendChild(tr); }
// visible rows with stripe pattern for (let i = start; i < end; i++) { const row = this.createRow(i); if (i % 2 === 1) row.classList.add(CLASSES.STRIPE); frag.appendChild(row); }
// spacer for hidden bottom rows if (end < this.itemCount) { const tr = document.createElement("tr"); tr.style.height = `${(this.itemCount - end) * this.rowHeight}px`; frag.appendChild(tr); }
this.tbody.replaceChildren(); this.tbody.appendChild(frag); this.onScroll?.(); }
// update item count and re-render with fresh data updateItemCount(itemCount) { this.itemCount = itemCount; this.render(true); }
// clean up event listeners and cancel pending renders destroy() { cancelAnimationFrame(this.rafId); this.container.removeEventListener("scroll", this.handleScheduleRender); window.removeEventListener("resize", this.handleScheduleRender); }}