tangled
alpha
login
or
join now
tokono.ma
/
diffuse
5
fork
atom
A music player that connects to your cloud/distributed storage.
5
fork
atom
overview
issues
4
pulls
pipelines
chore: virtual list webamp browser for perf
Steven Vandevelde
3 weeks ago
070112ed
8212146e
+149
-29
2 changed files
expand all
collapse all
unified
split
.gitignore
src
themes
webamp
browser
element.js
+1
.gitignore
···
1
1
.DS_Store
2
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
7
-
import { signal } from "@common/signal.js";
8
8
-
import { highlightTableEntry } from "../common/ui.js";
7
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
15
+
16
16
+
const ROW_HEIGHT = 14;
17
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
31
+
$provider = signal(
32
32
+
/** @type {DiffuseElement & { tracks: SignalReader<Track[]> } | undefined} */ (undefined),
33
33
+
);
34
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
37
-
$provider = signal(
38
38
-
/** @type {DiffuseElement & { tracks: SignalReader<Track[]> } | undefined} */ (undefined),
39
39
-
);
43
43
+
$highlightedTrack = signal(/** @type {string | null} */ (null));
44
44
+
45
45
+
// STATE
46
46
+
47
47
+
#scrollTop = 0;
48
48
+
#viewportHeight = 0;
49
49
+
#renderedStartIndex = -1;
50
50
+
#renderedEndIndex = -1;
51
51
+
52
52
+
/** @type {ResizeObserver | undefined} */
53
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
72
-
this.root().querySelector(".sunken-panel")?.scrollTo(0, 0);
86
86
+
87
87
+
untracked(() => {
88
88
+
const panel = this.root().querySelector(".sunken-panel");
89
89
+
if (panel) {
90
90
+
panel.scrollTo(0, 0);
91
91
+
this.#scrollTop = 0;
92
92
+
}
93
93
+
});
73
94
});
74
95
96
96
+
// Scroll & resize tracking (set up once after first render)
97
97
+
this.#setupScrollTracking();
98
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
109
+
/**
110
110
+
* @override
111
111
+
*/
112
112
+
disconnectedCallback() {
113
113
+
super.disconnectedCallback();
114
114
+
this.#resizeObserver?.disconnect();
115
115
+
}
116
116
+
117
117
+
// SCROLL
118
118
+
119
119
+
#setupScrollTracking() {
120
120
+
requestAnimationFrame(() => {
121
121
+
const panel = this.root().querySelector(".sunken-panel");
122
122
+
if (!panel) return;
123
123
+
124
124
+
panel.addEventListener(
125
125
+
"scroll",
126
126
+
() => {
127
127
+
this.#scrollTop = panel.scrollTop;
128
128
+
this.#renderIfWindowChanged(panel);
129
129
+
},
130
130
+
{ passive: true },
131
131
+
);
132
132
+
133
133
+
this.#resizeObserver = new ResizeObserver((entries) => {
134
134
+
this.#viewportHeight = entries[0].contentRect.height;
135
135
+
this.#renderIfWindowChanged(panel);
136
136
+
});
137
137
+
138
138
+
this.#resizeObserver.observe(panel);
139
139
+
});
140
140
+
}
141
141
+
142
142
+
#computeWindow() {
143
143
+
const startIndex = Math.max(
144
144
+
0,
145
145
+
Math.floor(this.#scrollTop / ROW_HEIGHT) - OVERSCAN,
146
146
+
);
147
147
+
const visibleCount = Math.ceil(this.#viewportHeight / ROW_HEIGHT) +
148
148
+
2 * OVERSCAN;
149
149
+
150
150
+
return { startIndex, endIndex: startIndex + visibleCount };
151
151
+
}
152
152
+
153
153
+
/**
154
154
+
* @param {Element} panel
155
155
+
*/
156
156
+
#renderIfWindowChanged(panel) {
157
157
+
const { startIndex, endIndex } = this.#computeWindow();
158
158
+
159
159
+
if (
160
160
+
startIndex === this.#renderedStartIndex &&
161
161
+
endIndex === this.#renderedEndIndex
162
162
+
) {
163
163
+
return;
164
164
+
}
165
165
+
166
166
+
const scrollTop = panel.scrollTop;
167
167
+
this.forceRender();
168
168
+
panel.scrollTop = scrollTop;
169
169
+
}
170
170
+
85
171
// EVENTS
86
172
87
173
/**
···
119
205
* @param {RenderArg} _
120
206
*/
121
207
render({ html }) {
208
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
212
+
213
213
+
// Virtual list
214
214
+
const totalTracks = tracks.length;
215
215
+
const { startIndex, endIndex: rawEnd } = this.#computeWindow();
216
216
+
const endIndex = Math.min(totalTracks, rawEnd);
217
217
+
218
218
+
this.#renderedStartIndex = startIndex;
219
219
+
this.#renderedEndIndex = endIndex;
220
220
+
221
221
+
const visibleTracks = tracks.slice(startIndex, endIndex);
222
222
+
const totalHeight = totalTracks * ROW_HEIGHT;
223
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
266
+
.virtual-header {
267
267
+
position: sticky;
268
268
+
top: 0;
269
269
+
z-index: 1;
270
270
+
}
271
271
+
167
272
table {
168
273
color: var(--text-color);
169
274
table-layout: fixed;
···
178
283
}
179
284
}
180
285
286
286
+
.virtual-scroll table {
287
287
+
will-change: transform;
288
288
+
}
289
289
+
181
290
table tbody tr {
182
291
cursor: pointer;
183
183
-
content-visibility: auto;
184
292
}
185
293
186
294
table td {
187
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
215
-
<table>
322
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
223
-
<tbody>
224
224
-
${isLoading
225
225
-
? html`
226
226
-
<tr>
227
227
-
<td>Loading ...</td>
228
228
-
<td></td>
229
229
-
<td></td>
230
230
-
</tr>
231
231
-
`
232
232
-
: tracks.map((track) => {
233
233
-
return html`
234
234
-
<tr @click="${highlightTableEntry}" @dblclick="${() =>
235
235
-
this.playTrack(track)}">
236
236
-
<td>${track.tags?.title}</td>
237
237
-
<td>${track.tags?.artist}</td>
238
238
-
<td>${track.tags?.album}</td>
239
239
-
</tr>
240
240
-
`;
241
241
-
})}
242
242
-
</tbody>
243
330
</table>
331
331
+
<div class="virtual-scroll" style="height:${totalHeight}px">
332
332
+
<table style="transform:translateY(${topPad}px)">
333
333
+
<colgroup>
334
334
+
<col style="width:40%">
335
335
+
<col style="width:30%">
336
336
+
<col style="width:30%">
337
337
+
</colgroup>
338
338
+
<tbody>
339
339
+
${isLoading
340
340
+
? html`
341
341
+
<tr>
342
342
+
<td>Loading ...</td>
343
343
+
<td></td>
344
344
+
<td></td>
345
345
+
</tr>
346
346
+
`
347
347
+
: visibleTracks.map((track) =>
348
348
+
html`
349
349
+
<tr
350
350
+
class="${highlighted === track.id ? `highlighted` : ``}"
351
351
+
@click="${() => this.$highlightedTrack.value = track.id}"
352
352
+
@dblclick="${() => this.playTrack(track)}"
353
353
+
>
354
354
+
<td>${track.tags?.title}</td>
355
355
+
<td>${track.tags?.artist}</td>
356
356
+
<td>${track.tags?.album}</td>
357
357
+
</tr>
358
358
+
`
359
359
+
)}
360
360
+
</tbody>
361
361
+
</table>
362
362
+
</div>
244
363
</div>
245
364
`;
246
365
}