a simple web player for subsonic
tinysub.devins.page
subsonic
navidrome
javascript
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}