a simple web player for subsonic
tinysub.devins.page
subsonic
navidrome
javascript
1<script lang="ts">
2 import {
3 queue,
4 goto,
5 select,
6 clearSel,
7 moveHead,
8 reorder,
9 remove,
10 settings,
11 toggleStar,
12 setRating,
13 formatTime,
14 playNext,
15 moveUp,
16 moveDown,
17 starSelected,
18 rateSelected,
19 selectAll,
20 moveSelected,
21 nav,
22 getSortItems,
23 t,
24 } from "./app.svelte.js";
25 import { api } from "./client.svelte.js";
26 import { ui } from "./app.svelte.js";
27 import ContextMenu from "./ContextMenu.svelte";
28 import VirtualList from "./VirtualList.svelte";
29
30 let menu = $state<{ x: number; y: number; index: number } | null>(null),
31 drag = $state<{ index: number; isAfter: boolean } | null>(null),
32 scrollToIndex = $state<(i: number) => void>();
33
34 $effect(() => {
35 if (nav.head >= 0) scrollToIndex?.(nav.head);
36 });
37
38 function onDragStart(e: DragEvent, index: number) {
39 if (!queue.sel.includes(index)) select(index);
40 e.dataTransfer!.effectAllowed = "move";
41 }
42
43 function onDragOver(e: DragEvent, index: number) {
44 e.preventDefault();
45 const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
46 drag = { index, isAfter: e.clientY > rect.top + rect.height / 2 };
47 }
48
49 function onDrop() {
50 if (drag) moveSelected(drag.index + (drag.isAfter ? 1 : 0));
51 drag = null;
52 }
53
54 function onKey(e: KeyboardEvent) {
55 if ((e.target as HTMLElement).tagName === "INPUT") return;
56 const key = e.key,
57 shift = e.shiftKey,
58 alt = e.altKey;
59 if (e.ctrlKey || e.metaKey) {
60 if (key === "a") {
61 e.preventDefault();
62 selectAll();
63 }
64 return;
65 }
66
67 const moves: Record<string, () => void> = {
68 ArrowUp: () => (alt ? reorder(-1) : moveHead(-1, shift)),
69 ArrowDown: () => (alt ? reorder(1) : moveHead(1, shift)),
70 Home: () => select(0, false, shift),
71 End: () => select(queue.tracks.length - 1, false, shift),
72 PageUp: () => moveHead(-10, shift),
73 PageDown: () => moveHead(10, shift),
74 Enter: () => queue.sel.length && goto(queue.sel[0]),
75 Delete: remove,
76 Backspace: remove,
77 Escape: clearSel,
78 ContextMenu: () => showMenu(nav.head),
79 "`": () => showMenu(nav.head),
80 F10: () => e.shiftKey && showMenu(nav.head),
81 };
82
83 if (moves[key]) {
84 e.preventDefault();
85 moves[key]();
86 }
87 }
88
89 function showMenu(index: number) {
90 if (index < 0) return;
91 if (!queue.sel.includes(index)) select(index);
92 // Find the row element to position the menu
93 const row = document.querySelector(`[data-id="queue-row-${index}"]`);
94 if (row) {
95 const rect = row.getBoundingClientRect();
96 menu = { x: rect.left + 50, y: rect.bottom, index };
97 } else {
98 // Fallback to a central-ish position if row not found (e.g. virtualized out)
99 menu = { x: window.innerWidth / 2, y: window.innerHeight / 3, index };
100 }
101 }
102</script>
103
104<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
105<!-- svelte-ignore a11y_no_static_element_interactions -->
106<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
107<div
108 id="queue"
109 style="grid-area: queue"
110 onclick={clearSel}
111 onkeydown={onKey}
112 onfocusin={() => (ui.activeContext = "queue")}
113 tabindex="0"
114>
115 <div class="head">
116 {#if settings.artSong > 0}<div
117 style="inline-size: {settings.artSong}px;"
118 ></div>{/if}
119 <div class="col">{t("title")}</div>
120 {#if settings.enableArtist}<div class="col">{t("artist")}</div>{/if}
121 {#if settings.enableAlbum}<div class="col">{t("album")}</div>{/if}
122 {#if settings.enableQuality}<div class="col-num">{t("quality")}</div>{/if}
123 {#if settings.enableDuration}<div class="col-num">{t("time")}</div>{/if}
124 {#if settings.enableDisc}<div class="col-num">{t("disc")}</div>{/if}
125 {#if settings.enableTrackNum}<div class="col-num">{t("track")}</div>{/if}
126 {#if settings.enableQueueNum}<div class="col-num">{t("queue")}</div>{/if}
127 {#if settings.enableFavorites}<div class="col-fav">♥</div>{/if}
128 {#if settings.enableRatings}<div class="col-rate">{t("rating")}</div>{/if}
129 <div class="col-touch"></div>
130 </div>
131
132 <div class="body">
133 <VirtualList
134 items={queue.tracks}
135 itemHeight={Math.max(18, settings.artSong + 2)}
136 // +2 for 1px padding top and bottom around icon
137 bind:scrollToIndex
138 >
139 {#snippet children(track, index)}
140 <!-- svelte-ignore a11y_click_events_have_key_events -->
141 <div
142 class="row"
143 data-id="queue-row-{index}"
144 class:playing={queue.pos === index}
145 class:selected={queue.sel.includes(index)}
146 class:odd={index % 2 !== 0}
147 class:over-a={drag?.index === index && !drag.isAfter}
148 class:over-b={drag?.index === index && drag.isAfter}
149 style="block-size: {Math.max(18, settings.artSong + 2)}px;"
150 // +2 for 1px padding top and bottom around icon
151 draggable={true}
152 ondragstart={(e) => onDragStart(e, index)}
153 ondragover={(e) => onDragOver(e, index)}
154 ondrop={onDrop}
155 ondragend={() => (drag = null)}
156 onclick={(e) => {
157 e.stopPropagation();
158 select(index, e.ctrlKey || e.metaKey, e.shiftKey);
159 }}
160 ondblclick={() => {
161 goto(index);
162 clearSel();
163 }}
164 oncontextmenu={(e) => {
165 e.preventDefault();
166 if (!queue.sel.includes(index)) select(index);
167 menu = { x: e.clientX, y: e.clientY, index };
168 }}
169 >
170 {#if settings.artSong > 0}
171 {@const artId = track.albumId || track.coverArt || track.id}
172 <div class="col-art" style="inline-size: {settings.artSong}px;">
173 <img
174 class="art"
175 src={api.art(artId, settings.artSong)}
176 srcset="{api.art(artId, settings.artSong)} 1x, {api.art(
177 artId,
178 settings.artSong * 2,
179 )} 2x"
180 style="inline-size: {settings.artSong}px; block-size: {settings.artSong}px;"
181 alt=""
182 loading="lazy"
183 />
184 </div>
185 {/if}
186 <div class="col">{track.title}</div>
187 {#if settings.enableArtist}<div class="col">{track.artist}</div>{/if}
188 {#if settings.enableAlbum}<div class="col">{track.album}</div>{/if}
189 {#if settings.enableQuality}
190 <div class="col-num">
191 {track.suffix || ""}{#if track.suffix && track.bitRate}
192 {" "}{/if}{track.bitRate || ""}
193 </div>
194 {/if}
195 {#if settings.enableDuration}
196 <div class="col-num">{formatTime(track.duration)}</div>
197 {/if}
198 {#if settings.enableDisc}<div class="col-num">
199 {track.discNumber || ""}
200 </div>{/if}
201 {#if settings.enableTrackNum}<div class="col-num">
202 {track.track || ""}
203 </div>{/if}
204 {#if settings.enableQueueNum}<div class="col-num">
205 {index + 1}
206 </div>{/if}
207 {#if settings.enableFavorites}
208 <div class="col-fav">
209 <button
210 tabindex="-1"
211 class="btn-fav"
212 class:active={track.starred}
213 onclick={(e) => {
214 e.stopPropagation();
215 toggleStar(track);
216 }}
217 >
218 ♥
219 </button>
220 </div>
221 {/if}
222 {#if settings.enableRatings}
223 <div class="col-rate">
224 {#each [1, 2, 3, 4, 5] as rating}
225 <button
226 tabindex="-1"
227 class="btn-rate"
228 class:active={track.userRating >= rating}
229 onclick={(e) => {
230 e.stopPropagation();
231 setRating(track, track.userRating === rating ? 0 : rating);
232 }}
233 >
234 ★
235 </button>
236 {/each}
237 </div>
238 {/if}
239 <div class="col-touch">
240 <button
241 tabindex="-1"
242 class="btn-touch"
243 onclick={(e) => {
244 e.stopPropagation();
245 if (!queue.sel.includes(index)) select(index);
246 reorder(-1);
247 }}
248 title={t("move_up")}
249 >
250 ▲
251 </button>
252 <button
253 tabindex="-1"
254 class="btn-touch"
255 onclick={(e) => {
256 e.stopPropagation();
257 if (!queue.sel.includes(index)) select(index);
258 reorder(1);
259 }}
260 title={t("move_down")}
261 >
262 ▼
263 </button>
264 <button
265 tabindex="-1"
266 class="btn-touch"
267 onclick={(e) => {
268 e.stopPropagation();
269 goto(index);
270 clearSel();
271 }}
272 title={t("play")}
273 >
274 ▶
275 </button>
276 </div>
277 </div>
278 {/snippet}
279 </VirtualList>
280 </div>
281</div>
282
283{#if menu}
284 <ContextMenu
285 x={menu.x}
286 y={menu.y}
287 onclose={() => (menu = null)}
288 items={[
289 { label: t("play"), action: () => goto(menu!.index) },
290 { label: t("play_next"), action: playNext },
291 {
292 label: t("sort"),
293 items: getSortItems(),
294 },
295 { label: t("favorite"), action: () => starSelected(true) },
296 { label: t("unfavorite"), action: () => starSelected(false) },
297 {
298 label: t("rating"),
299 items: [
300 { label: "★★★★★", action: () => rateSelected(5) },
301 { label: "★★★★", action: () => rateSelected(4) },
302 { label: "★★★", action: () => rateSelected(3) },
303 { label: "★★", action: () => rateSelected(2) },
304 { label: "★", action: () => rateSelected(1) },
305 { label: t("none"), action: () => rateSelected(0) },
306 ],
307 },
308 { label: t("move_up"), action: moveUp },
309 { label: t("move_down"), action: moveDown },
310 {
311 label: t("clear"),
312 action: remove,
313 },
314 ]}
315 />
316{/if}
317
318<style>
319 #queue {
320 display: flex;
321 flex-direction: column;
322 overflow: hidden;
323 padding-block-start: 0.5rem;
324 }
325 .body {
326 flex: 1;
327 overflow: hidden;
328 }
329 .head {
330 font-weight: bold;
331 color: inherit;
332 padding-inline: 1rem;
333 }
334 .head,
335 .row {
336 display: flex;
337 align-items: center;
338 }
339 .col {
340 flex: 1;
341 padding-inline: 0.5rem;
342 white-space: nowrap;
343 overflow: hidden;
344 text-overflow: ellipsis;
345 }
346 .col-num {
347 inline-size: 6rem;
348 text-align: end;
349 }
350 .col-fav {
351 inline-size: 2rem;
352 text-align: end;
353 }
354 .col-rate {
355 inline-size: 6rem;
356 text-align: end;
357 }
358 .col-art {
359 display: flex;
360 justify-content: center;
361 }
362 .row.odd {
363 background: var(--bg-row);
364 }
365 .row.playing {
366 background: var(--playing);
367 }
368 .row.selected {
369 background: Highlight;
370 color: HighlightText;
371 }
372 #queue:not(:focus-within) .row.selected {
373 background: GrayText;
374 color: HighlightText;
375 }
376 .row.over-a {
377 box-shadow: inset 0 2px 0 var(--text);
378 }
379 .row.over-b {
380 box-shadow: inset 0 -2px 0 var(--text);
381 }
382 .btn-fav,
383 .btn-rate {
384 background: none;
385 border: none;
386 padding: 0;
387 opacity: 0.25;
388 }
389 .btn-rate {
390 transform: scale(0.75);
391 }
392 .btn-fav.active {
393 color: #ff0008;
394 opacity: 1;
395 }
396 .btn-rate.active {
397 color: #ffd700;
398 opacity: 1;
399 transform: scale(1);
400 }
401 .btn-fav:hover,
402 .btn-rate:hover {
403 opacity: 0.75;
404 }
405 .col-touch {
406 display: none;
407 inline-size: 6rem;
408 justify-content: flex-end;
409 }
410 @media (pointer: coarse) {
411 .col-touch {
412 display: flex;
413 }
414 }
415 .btn-touch {
416 background: none;
417 border: none;
418 padding: 0;
419 opacity: 0.25;
420 font-size: 0.75rem;
421 display: flex;
422 align-items: center;
423 justify-content: center;
424 inline-size: 1.5rem;
425 block-size: 1rem;
426 }
427 :global(body.rounded-art) #queue .art,
428 :global(body.rounded-art) #queue .row {
429 border-radius: 2px;
430 }
431</style>