a simple web player for subsonic tinysub.devins.page
subsonic navidrome javascript
at main 122 lines 3.1 kB view raw
1// virtual scrolling for efficient rendering of massive queues 2 3const STRIPE_CLASS = "stripe"; 4 5class VirtualScroller { 6 // initialize virtual scroller with container, tbody, and row factory 7 constructor( 8 container, 9 tbody, 10 itemCount, 11 createRow, 12 { buffer = 32, onScroll } = {}, 13 ) { 14 Object.assign(this, { 15 container, 16 tbody, 17 itemCount, 18 createRow, 19 buffer, 20 onScroll, 21 }); 22 this.visibleStart = this.visibleEnd = 0; 23 this.rafId = null; 24 this.firstRender = true; 25 this.rowHeight = 0; 26 27 this.handleScroll = () => this.scheduleRender(); 28 this.handleResize = () => this.scheduleRender(); 29 30 this.container.addEventListener("scroll", this.handleScroll, { 31 passive: true, 32 }); 33 window.addEventListener("resize", this.handleResize); 34 this.render(); 35 } 36 37 // batch scroll and resize events into a single render call via requestAnimationFrame 38 scheduleRender() { 39 if (this.rafId) return; 40 this.rafId = requestAnimationFrame(() => { 41 this.rafId = null; 42 this.render(); 43 }); 44 } 45 46 // render visible rows with spacer rows for scroll height 47 render(forceRedraw = false) { 48 // measure row height on first render or when settings change 49 if (this.firstRender || forceRedraw) { 50 if (this.itemCount > 0) { 51 const sample = this.createRow(0); 52 this.tbody.appendChild(sample); 53 this.rowHeight = sample.offsetHeight; 54 sample.remove(); 55 } 56 this.firstRender = false; 57 } 58 59 if (!this.itemCount) { 60 this.tbody.replaceChildren(); 61 return; 62 } 63 64 // calculate visible range based on scroll position 65 const scrollTop = this.container.scrollTop; 66 const start = Math.max( 67 0, 68 Math.floor(scrollTop / this.rowHeight) - this.buffer, 69 ); 70 const end = Math.min( 71 this.itemCount, 72 Math.ceil((scrollTop + this.container.clientHeight) / this.rowHeight) + 73 this.buffer, 74 ); 75 76 // skip render if visible range hasn't changed 77 if (!forceRedraw && start === this.visibleStart && end === this.visibleEnd) 78 return; 79 80 this.visibleStart = start; 81 this.visibleEnd = end; 82 83 const rows = []; 84 85 // top spacer for rows before visible range 86 if (start > 0) { 87 const tr = document.createElement("tr"); 88 tr.style.height = `${start * this.rowHeight}px`; 89 rows.push(tr); 90 } 91 92 // visible rows with alternating stripe class 93 for (let i = start; i < end; i++) { 94 const row = this.createRow(i); 95 if (i % 2 === 1) row.classList.add(STRIPE_CLASS); 96 rows.push(row); 97 } 98 99 // bottom spacer for rows after visible range 100 if (end < this.itemCount) { 101 const tr = document.createElement("tr"); 102 tr.style.height = `${(this.itemCount - end) * this.rowHeight}px`; 103 rows.push(tr); 104 } 105 106 this.tbody.replaceChildren(...rows); 107 this.onScroll?.(); 108 } 109 110 // update item count and re-render with fresh data 111 updateItemCount(itemCount) { 112 this.itemCount = itemCount; 113 this.render(true); 114 } 115 116 // clean up event listeners and cancel pending renders 117 destroy() { 118 cancelAnimationFrame(this.rafId); 119 this.container.removeEventListener("scroll", this.handleScroll); 120 window.removeEventListener("resize", this.handleResize); 121 } 122}