a simple web player for subsonic
tinysub.devins.page
subsonic
navidrome
javascript
1<script lang="ts">
2 import { untrack } from "svelte";
3 import { api } from "./client.svelte.js";
4 import {
5 insertAt,
6 queue,
7 auth,
8 loadLib,
9 player,
10 recursiveSongs,
11 settings,
12 lib,
13 t,
14 } from "./app.svelte.js";
15 import { ui } from "./app.svelte.js";
16 import ContextMenu from "./ContextMenu.svelte";
17 import TreeItem from "./TreeItem.svelte";
18 import icAdd from "../assets/famfamfam-silk-svg/icons/Add.svg";
19 import icDisk from "../assets/famfamfam-silk-svg/icons/Disk.svg";
20 import icBinEmpty from "../assets/famfamfam-silk-svg/icons/Bin empty.svg";
21 import icWorld from "../assets/famfamfam-silk-svg/icons/World.svg";
22 import icHeart from "../assets/famfamfam-silk-svg/icons/Heart.svg";
23 import icLink from "../assets/famfamfam-silk-svg/icons/Link.svg";
24
25 let {
26 id,
27 uid = id,
28 label,
29 fetcher,
30 childFactory,
31 isTrack = false,
32 track = null,
33 type = "artist",
34 isReadonly = false,
35 owner = null,
36 isPublic = false,
37 item = null,
38 } = $props<{
39 id: string;
40 uid?: string;
41 label: string;
42 fetcher?: () => Promise<any[]>;
43 childFactory?: (item: any) => (() => Promise<any[]>) | undefined;
44 isTrack?: boolean;
45 track?: any;
46 type?: "artist" | "album" | "playlist" | "song" | "share";
47 isReadonly?: boolean;
48 owner?: string | null;
49 isPublic?: boolean;
50 item?: any;
51 }>();
52
53 let open = $state(false),
54 items = $state<any[] | null>(null),
55 busy = $state(false),
56 menu = $state<{ x: number; y: number } | null>(null);
57
58 let publicOverride = $state<boolean | null>(null);
59 let isPublicState = $derived(publicOverride ?? isPublic);
60
61 let starredOverride = $state<string | undefined | null>(null);
62 let isStarred = $derived(starredOverride ?? item?.starred);
63
64 async function doToggleStar() {
65 if (type !== "album" && type !== "artist") return;
66 const next = !isStarred;
67 try {
68 const params = { id };
69 starredOverride = next ? new Date().toISOString() : undefined;
70 next ? await api.star(params) : await api.unstar(params);
71 if (item) item.starred = starredOverride;
72 starredOverride = null;
73 } catch (err) {
74 console.error(err);
75 starredOverride = null;
76 }
77 }
78
79 async function doTogglePublic() {
80 if (type !== "playlist" || (owner !== auth.user && !auth.admin)) return;
81 try {
82 const next = !isPublicState;
83 await api.updatePlaylist(id, undefined, undefined, undefined, next);
84 publicOverride = next;
85 await loadLib();
86 publicOverride = null;
87 } catch (err) {
88 console.error(err);
89 }
90 }
91
92 $effect(() => {
93 const signal = ui.lastUpdatedPlaylistId;
94 if (type === "playlist" && signal?.startsWith(id + ":")) {
95 if (untrack(() => open) && fetcher) {
96 busy = true;
97 fetcher()
98 .then((res: any[]) => (items = res))
99 .catch((err: any) => console.error(err))
100 .finally(() => (busy = false));
101 } else {
102 items = null;
103 }
104 }
105 });
106
107 async function doAdd(next: boolean) {
108 const index = next ? queue.pos + 1 : queue.tracks.length;
109 if (isTrack && track) {
110 insertAt(index, { ...track });
111 } else if (fetcher) {
112 busy = true;
113 try {
114 insertAt(index, await recursiveSongs(await fetcher(), childFactory));
115 } catch (err) {
116 console.error(err);
117 } finally {
118 busy = false;
119 }
120 }
121 }
122
123 async function toggle(e?: Event) {
124 e?.stopPropagation();
125 if (isTrack) return;
126 open = !open;
127 if (open && !items && fetcher) {
128 busy = true;
129 try {
130 items = await fetcher();
131 } finally {
132 busy = false;
133 }
134 }
135 }
136
137 function doUpdate() {
138 if (canUpdate) {
139 ui.confirmUpdatePlaylistId = id;
140 ui.confirmUpdatePlaylistName = label;
141 }
142 }
143
144 function doRename() {
145 if (canDelete) {
146 ui.renamePlaylistId = id;
147 ui.renamePlaylistName = label;
148 }
149 }
150
151 async function doDelete() {
152 if (type === "share") {
153 try {
154 await api.deleteShare(id);
155 await loadLib();
156 } catch (err) {
157 console.error(err);
158 }
159 return;
160 }
161 if (canDelete) {
162 ui.confirmDeletePlaylistId = id;
163 ui.confirmDeletePlaylistName = label;
164 }
165 }
166
167 function keyboardActions(node: HTMLElement) {
168 const onAdd = (e: any) => doAdd(e.detail);
169 node.addEventListener("actionadd", onAdd);
170 return {
171 destroy: () => node.removeEventListener("actionadd", onAdd),
172 };
173 }
174
175 function onDragStart(e: DragEvent) {
176 e.dataTransfer!.effectAllowed = "copy";
177 e.dataTransfer!.setData("application/tinysub-library", "");
178 (window as any)._tinysub_drag = async () => {
179 if (isTrack && track) return { ...track };
180 if (fetcher) return recursiveSongs(await fetcher(), childFactory);
181 return [];
182 };
183 }
184
185 function onDragEnd() {
186 delete (window as any)._tinysub_drag;
187 }
188
189 const artSize = $derived(
190 (
191 {
192 artist: settings.artArtist,
193 album: settings.artAlbum,
194 playlist: settings.artPlaylist,
195 } as any
196 )[type] ?? 0,
197 );
198
199 const isOwner = $derived(!owner || (auth.user && owner === auth.user));
200 const canUpdate = $derived(
201 type === "playlist" &&
202 (auth.admin ? (isOwner ? !isReadonly : true) : isOwner && !isReadonly),
203 );
204 const canDelete = $derived(
205 type === "playlist" && (auth.admin || (isOwner && !isReadonly)),
206 );
207</script>
208
209<li>
210 <div class="row">
211 <button
212 tabindex="-1"
213 class:focused={lib.focusedId === uid}
214 class="btn main"
215 data-id={uid}
216 draggable={true}
217 onclick={toggle}
218 ondragend={onDragEnd}
219 ondragstart={onDragStart}
220 oncontextmenu={(e) => {
221 e.preventDefault();
222 menu = { x: e.clientX, y: e.clientY };
223 }}
224 use:keyboardActions
225 >
226 {#if artSize > 0}
227 {@const artId = track?.albumId || track?.coverArt || id}
228 <div class="art-container">
229 <img
230 alt=""
231 class="art"
232 loading="lazy"
233 src={api.art(artId, artSize)}
234 srcset="{api.art(artId, artSize)} 1x, {api.art(
235 artId,
236 artSize * 2,
237 )} 2x"
238 style="inline-size: {artSize}px; block-size: {artSize}px;"
239 />
240 {#if type === "playlist" && isPublicState}
241 <img alt="" class="badge" src={icWorld} title={t("public")} />
242 {/if}
243 {#if (type === "album" || type === "artist") && isStarred}
244 <img alt="" class="badge" src={icHeart} title={t("favorite")} />
245 {/if}
246 </div>
247 {/if}
248 <div class="label-stack">
249 <span class="label">{label}</span>
250 {#if owner && type === "playlist" && owner.toLowerCase() !== auth.user?.toLowerCase()}
251 <span class="owner">owner: {owner}</span>
252 {/if}
253 </div>
254 </button>
255 {#if canUpdate}
256 <button
257 tabindex="-1"
258 class="btn action"
259 onclick={(e) => {
260 e.stopPropagation();
261 doUpdate();
262 }}
263 title={t("update_playlist")}
264 >
265 <img alt="" src={icDisk} />
266 </button>
267 {/if}
268 {#if canDelete || type === "share"}
269 <button
270 tabindex="-1"
271 class="btn action"
272 onclick={(e) => {
273 e.stopPropagation();
274 doDelete();
275 }}
276 title={type === "share" ? t("delete_share") : t("delete_playlist")}
277 >
278 <img alt="" src={icBinEmpty} />
279 </button>
280 {/if}
281 {#if type === "share"}
282 <button
283 tabindex="-1"
284 class="btn action"
285 onclick={(e) => {
286 e.stopPropagation();
287 if (item?.url) navigator.clipboard.writeText(item.url);
288 }}
289 title={t("copy_link")}
290 >
291 <img alt="" src={icLink} />
292 </button>
293 {/if}
294 {#if type !== "share"}
295 <button
296 tabindex="-1"
297 class="btn action"
298 onclick={(e) => {
299 e.stopPropagation();
300 doAdd(false);
301 }}
302 title={t("add_to_queue")}
303 >
304 <img alt="" src={icAdd} />
305 </button>
306 {/if}
307 <button
308 tabindex="-1"
309 class="btn touch"
310 onclick={(e) => {
311 e.stopPropagation();
312 menu = { x: window.innerWidth, y: e.clientY };
313 }}
314 title={t("context_menu")}
315 >
316 ⋮
317 </button>
318 </div>
319
320 {#if open}
321 <ul>
322 {#if busy}
323 <li>{t("busy")}</li>
324 {:else if items}
325 {#each items as item, i (item.id + i)}
326 <TreeItem
327 childFactory={undefined}
328 fetcher={childFactory?.(item)}
329 id={item.id}
330 isTrack={type !== "artist"}
331 {item}
332 label={item.name || item.title}
333 track={type !== "artist" ? item : null}
334 type={type === "artist" ? "album" : "song"}
335 uid={id + ":" + item.id + ":" + i}
336 />
337 {/each}
338 {/if}
339 </ul>
340 {/if}
341</li>
342
343{#if menu}
344 <ContextMenu
345 onclose={() => (menu = null)}
346 x={menu.x}
347 y={menu.y}
348 items={[
349 type !== "share" && { label: t("add"), action: () => doAdd(false) },
350 type !== "share" && { label: t("add_next"), action: () => doAdd(true) },
351 type !== "share" && { type: "separator" },
352 lib.isSharingSupported &&
353 type !== "share" && {
354 label: t("share"),
355 action: () => {
356 ui.shareItemId = id;
357 ui.shareItemLabel = label;
358 ui.shareItemType = type;
359 },
360 },
361 type === "share" && {
362 label: t("copy_link"),
363 action: () => {
364 if (item?.url) navigator.clipboard.writeText(item.url);
365 },
366 },
367 ...(type === "album" || type === "artist"
368 ? [
369 {
370 label: isStarred ? t("unfavorite") : t("favorite"),
371 action: doToggleStar,
372 },
373 ]
374 : []),
375 ...(type === "playlist"
376 ? [
377 ...(canUpdate ? [{ label: t("update"), action: doUpdate }] : []),
378 ...(canDelete
379 ? [
380 { label: t("rename"), action: doRename },
381 ...(isOwner || auth.admin
382 ? [
383 {
384 label: isPublicState
385 ? t("make_private")
386 : t("make_public"),
387 action: doTogglePublic,
388 },
389 ]
390 : []),
391 { label: t("delete"), action: doDelete },
392 ]
393 : []),
394 ]
395 : []),
396 type === "share" && { label: t("delete"), action: doDelete },
397 ].filter(Boolean) as any}
398 />
399{/if}
400
401<style>
402 ul {
403 list-style: none;
404 padding-inline-start: 1rem;
405 }
406 .row {
407 display: flex;
408 align-items: center;
409 gap: 0.25rem;
410 margin-block-start: 0.5rem;
411 }
412 .btn.main.focused {
413 background: Highlight;
414 color: HighlightText;
415 }
416 :global(#library:not(:focus-within)) .btn.main.focused {
417 background: GrayText;
418 color: HighlightText;
419 }
420 .btn {
421 background: none;
422 border: none;
423 display: flex;
424 align-items: center;
425 }
426 .main {
427 flex: 1;
428 overflow: hidden;
429 gap: 0.5rem;
430 }
431 @media (pointer: coarse) {
432 .action {
433 display: none;
434 }
435 }
436 .art {
437 object-fit: cover;
438 }
439 .touch {
440 display: none;
441 }
442 @media (pointer: coarse) {
443 .touch {
444 display: block;
445 padding: 0.5rem;
446 margin: -0.5rem;
447 }
448 }
449 .label {
450 overflow: hidden;
451 text-overflow: ellipsis;
452 white-space: nowrap;
453 inline-size: 100%;
454 text-align: start;
455 }
456 .label-stack {
457 display: flex;
458 flex-direction: column;
459 align-items: flex-start;
460 overflow: hidden;
461 flex: 1;
462 }
463 .owner {
464 font-size: 0.8rem;
465 opacity: 0.75;
466 }
467 .art-container {
468 position: relative;
469 }
470 .badge {
471 position: absolute;
472 inset-block-end: 0;
473 inset-inline-end: 0;
474 }
475 img {
476 display: block;
477 }
478 button {
479 padding: 0;
480 color: inherit;
481 }
482 :global(body.rounded-art) .art {
483 border-radius: 2px;
484 }
485</style>