A music player that connects to your cloud/distributed storage.

chore: virtual list webamp browser for perf

+149 -29
+1
.gitignore
··· 1 1 .DS_Store 2 + AGENTS.md 2 3 node_modules 3 4 4 5 /.claude
+148 -29
src/themes/webamp/browser/element.js
··· 4 4 query, 5 5 whenElementsDefined, 6 6 } from "@common/element.js"; 7 - import { signal } from "@common/signal.js"; 8 - import { highlightTableEntry } from "../common/ui.js"; 7 + import { signal, untracked } from "@common/signal.js"; 9 8 10 9 /** 11 10 * @import {RenderArg} from "@common/element.d.ts" ··· 13 12 * @import {Track} from "@definitions/types.d.ts" 14 13 * @import {OutputElement} from "@components/output/types.d.ts" 15 14 */ 15 + 16 + const ROW_HEIGHT = 14; 17 + const OVERSCAN = 20; 16 18 17 19 class Browser extends DiffuseElement { 18 20 constructor() { ··· 26 28 /** @type {OutputElement | undefined} */ (undefined), 27 29 ); 28 30 31 + $provider = signal( 32 + /** @type {DiffuseElement & { tracks: SignalReader<Track[]> } | undefined} */ (undefined), 33 + ); 34 + 29 35 $queue = signal( 30 36 /** @type {import("@components/engine/queue/element.js").CLASS | undefined} */ (undefined), 31 37 ); ··· 34 40 /** @type {import("@components/engine/scope/element.js").CLASS | undefined} */ (undefined), 35 41 ); 36 42 37 - $provider = signal( 38 - /** @type {DiffuseElement & { tracks: SignalReader<Track[]> } | undefined} */ (undefined), 39 - ); 43 + $highlightedTrack = signal(/** @type {string | null} */ (null)); 44 + 45 + // STATE 46 + 47 + #scrollTop = 0; 48 + #viewportHeight = 0; 49 + #renderedStartIndex = -1; 50 + #renderedEndIndex = -1; 51 + 52 + /** @type {ResizeObserver | undefined} */ 53 + #resizeObserver; 40 54 41 55 // LIFECYCLE 42 56 ··· 69 83 // Effects 70 84 this.effect(() => { 71 85 const _results = this.$provider.value?.tracks(); 72 - this.root().querySelector(".sunken-panel")?.scrollTo(0, 0); 86 + 87 + untracked(() => { 88 + const panel = this.root().querySelector(".sunken-panel"); 89 + if (panel) { 90 + panel.scrollTo(0, 0); 91 + this.#scrollTop = 0; 92 + } 93 + }); 73 94 }); 74 95 96 + // Scroll & resize tracking (set up once after first render) 97 + this.#setupScrollTracking(); 98 + 75 99 this.effect(() => { 76 100 const playlistId = this.$scope.value?.playlistId(); 77 101 const select = this.root().querySelector("#playlist-select"); ··· 82 106 }); 83 107 } 84 108 109 + /** 110 + * @override 111 + */ 112 + disconnectedCallback() { 113 + super.disconnectedCallback(); 114 + this.#resizeObserver?.disconnect(); 115 + } 116 + 117 + // SCROLL 118 + 119 + #setupScrollTracking() { 120 + requestAnimationFrame(() => { 121 + const panel = this.root().querySelector(".sunken-panel"); 122 + if (!panel) return; 123 + 124 + panel.addEventListener( 125 + "scroll", 126 + () => { 127 + this.#scrollTop = panel.scrollTop; 128 + this.#renderIfWindowChanged(panel); 129 + }, 130 + { passive: true }, 131 + ); 132 + 133 + this.#resizeObserver = new ResizeObserver((entries) => { 134 + this.#viewportHeight = entries[0].contentRect.height; 135 + this.#renderIfWindowChanged(panel); 136 + }); 137 + 138 + this.#resizeObserver.observe(panel); 139 + }); 140 + } 141 + 142 + #computeWindow() { 143 + const startIndex = Math.max( 144 + 0, 145 + Math.floor(this.#scrollTop / ROW_HEIGHT) - OVERSCAN, 146 + ); 147 + const visibleCount = Math.ceil(this.#viewportHeight / ROW_HEIGHT) + 148 + 2 * OVERSCAN; 149 + 150 + return { startIndex, endIndex: startIndex + visibleCount }; 151 + } 152 + 153 + /** 154 + * @param {Element} panel 155 + */ 156 + #renderIfWindowChanged(panel) { 157 + const { startIndex, endIndex } = this.#computeWindow(); 158 + 159 + if ( 160 + startIndex === this.#renderedStartIndex && 161 + endIndex === this.#renderedEndIndex 162 + ) { 163 + return; 164 + } 165 + 166 + const scrollTop = panel.scrollTop; 167 + this.forceRender(); 168 + panel.scrollTop = scrollTop; 169 + } 170 + 85 171 // EVENTS 86 172 87 173 /** ··· 119 205 * @param {RenderArg} _ 120 206 */ 121 207 render({ html }) { 208 + const highlighted = this.$highlightedTrack.value; 122 209 const isLoading = this.$output.value?.tracks?.state() !== "loaded"; 123 210 const tracks = this.$provider.value?.tracks() ?? []; 124 211 const playlistId = this.$scope.value?.playlistId(); 212 + 213 + // Virtual list 214 + const totalTracks = tracks.length; 215 + const { startIndex, endIndex: rawEnd } = this.#computeWindow(); 216 + const endIndex = Math.min(totalTracks, rawEnd); 217 + 218 + this.#renderedStartIndex = startIndex; 219 + this.#renderedEndIndex = endIndex; 220 + 221 + const visibleTracks = tracks.slice(startIndex, endIndex); 222 + const totalHeight = totalTracks * ROW_HEIGHT; 223 + const topPad = startIndex * ROW_HEIGHT; 125 224 126 225 return html` 127 226 <link rel="stylesheet" href="styles/vendor/98.css" /> ··· 164 263 resize: both; 165 264 } 166 265 266 + .virtual-header { 267 + position: sticky; 268 + top: 0; 269 + z-index: 1; 270 + } 271 + 167 272 table { 168 273 color: var(--text-color); 169 274 table-layout: fixed; ··· 178 283 } 179 284 } 180 285 286 + .virtual-scroll table { 287 + will-change: transform; 288 + } 289 + 181 290 table tbody tr { 182 291 cursor: pointer; 183 - content-visibility: auto; 184 292 } 185 293 186 294 table td { 187 - contain-intrinsic-size: auto 14px; 188 295 overflow: hidden; 189 296 text-overflow: ellipsis; 190 297 } ··· 212 319 </search> 213 320 214 321 <div class="sunken-panel"> 215 - <table> 322 + <table class="virtual-header"> 216 323 <thead> 217 324 <tr> 218 325 <th>Title</th> ··· 220 327 <th>Album</th> 221 328 </tr> 222 329 </thead> 223 - <tbody> 224 - ${isLoading 225 - ? html` 226 - <tr> 227 - <td>Loading ...</td> 228 - <td></td> 229 - <td></td> 230 - </tr> 231 - ` 232 - : tracks.map((track) => { 233 - return html` 234 - <tr @click="${highlightTableEntry}" @dblclick="${() => 235 - this.playTrack(track)}"> 236 - <td>${track.tags?.title}</td> 237 - <td>${track.tags?.artist}</td> 238 - <td>${track.tags?.album}</td> 239 - </tr> 240 - `; 241 - })} 242 - </tbody> 243 330 </table> 331 + <div class="virtual-scroll" style="height:${totalHeight}px"> 332 + <table style="transform:translateY(${topPad}px)"> 333 + <colgroup> 334 + <col style="width:40%"> 335 + <col style="width:30%"> 336 + <col style="width:30%"> 337 + </colgroup> 338 + <tbody> 339 + ${isLoading 340 + ? html` 341 + <tr> 342 + <td>Loading ...</td> 343 + <td></td> 344 + <td></td> 345 + </tr> 346 + ` 347 + : visibleTracks.map((track) => 348 + html` 349 + <tr 350 + class="${highlighted === track.id ? `highlighted` : ``}" 351 + @click="${() => this.$highlightedTrack.value = track.id}" 352 + @dblclick="${() => this.playTrack(track)}" 353 + > 354 + <td>${track.tags?.title}</td> 355 + <td>${track.tags?.artist}</td> 356 + <td>${track.tags?.album}</td> 357 + </tr> 358 + ` 359 + )} 360 + </tbody> 361 + </table> 362 + </div> 244 363 </div> 245 364 `; 246 365 }