a simple web player for subsonic
tinysub.devins.page
subsonic
navidrome
javascript
1<script lang="ts">
2 import { lib, t, auth } from "./app.svelte.js";
3 import { api, asArray } from "./client.svelte.js";
4 import { ui } from "./app.svelte.js";
5 import TreeItem from "./TreeItem.svelte";
6
7 let query = $state(""),
8 results = $state<any>(null);
9
10 let isArtistsExpanded = $state(true);
11 let isPlaylistsExpanded = $state(true);
12 let isSharedPlaylistsExpanded = $state(true);
13 let isSharesExpanded = $state(true);
14
15 let myPlaylists = $derived(
16 lib.playlists.filter(
17 (p) =>
18 !p.owner ||
19 (auth.user && p.owner.toLowerCase() === auth.user.toLowerCase()),
20 ),
21 );
22 let sharedPlaylists = $derived(
23 lib.playlists.filter(
24 (p) =>
25 p.owner &&
26 auth.user &&
27 p.owner.toLowerCase() !== auth.user.toLowerCase(),
28 ),
29 );
30
31 $effect(() => {
32 if (!query.trim()) {
33 results = null;
34 return;
35 }
36 const timer = setTimeout(async () => {
37 results = (await api.search(query)) || {};
38 }, 300);
39 return () => clearTimeout(timer);
40 });
41
42 function onKey(e: KeyboardEvent) {
43 if ((e.target as HTMLElement).tagName === "INPUT") return;
44 const elements = Array.from(
45 document.querySelectorAll("#library [data-id]"),
46 ) as HTMLElement[];
47 const focusedIndex = elements.findIndex((el) =>
48 el.classList.contains("focused"),
49 );
50 const { key, code, altKey: alt, shiftKey: shift } = e;
51
52 if (key === "Escape") return (e.preventDefault(), (lib.focusedId = null));
53
54 const offsets: Record<string, number> = {
55 ArrowDown: 1,
56 ArrowUp: -1,
57 PageDown: 10,
58 PageUp: -10,
59 Home: -Infinity,
60 End: Infinity,
61 };
62
63 if (key in offsets) {
64 e.preventDefault();
65 const targetIndex = Math.max(
66 0,
67 Math.min(elements.length - 1, focusedIndex + offsets[key]),
68 );
69 const next = elements[targetIndex];
70 if (next) {
71 lib.focusedId = next.dataset.id || null;
72 next.scrollIntoView({ block: "nearest" });
73 }
74 } else if (code === "Enter") {
75 e.preventDefault();
76 elements[focusedIndex]?.click();
77 } else if (code === "KeyA") {
78 e.preventDefault();
79 elements[focusedIndex]?.dispatchEvent(
80 new CustomEvent("actionadd", { detail: alt }),
81 );
82 } else if (
83 key === "ContextMenu" ||
84 key === "`" ||
85 (key === "F10" && shift)
86 ) {
87 e.preventDefault();
88 const el = elements[focusedIndex] as HTMLElement;
89 if (el) {
90 const rect = el.getBoundingClientRect();
91 el.dispatchEvent(
92 new MouseEvent("contextmenu", {
93 bubbles: true,
94 cancelable: true,
95 clientX: rect.left + 50,
96 clientY: rect.bottom,
97 }),
98 );
99 }
100 }
101 }
102</script>
103
104<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
105<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
106<nav
107 id="library"
108 onfocusin={() => (ui.activeContext = "library")}
109 onkeydown={onKey}
110 onclick={(e) => {
111 if (e.target === e.currentTarget) lib.focusedId = null;
112 }}
113 tabindex="0"
114>
115 <input
116 id="search"
117 type="search"
118 bind:value={query}
119 placeholder={t("search")}
120 onfocusin={(e) => e.stopPropagation()}
121 onclick={(e) => e.stopPropagation()}
122 />
123 <ul class="tree">
124 {#if query && results}
125 {#each ["artist", "album", "song"] as type}
126 {@const items = asArray(results[type])}
127 {#if items.length}
128 <li class="section">
129 <button
130 tabindex="-1"
131 class="label"
132 class:focused={lib.focusedId === `search-${type}`}
133 data-id={`search-${type}`}
134 onclick={(e) => e.stopPropagation()}
135 >
136 {t((type + "s") as any)}
137 </button>
138 <ul>
139 {#each items as item, itemIndex (item.id + itemIndex)}
140 <TreeItem
141 id={item.id}
142 uid={`search-${type}-${item.id}-${itemIndex}`}
143 label={item.name || item.title}
144 fetcher={type === "artist"
145 ? () => api.artist(item.id)
146 : type === "album"
147 ? () => api.album(item.id)
148 : undefined}
149 childFactory={type === "artist"
150 ? (a: any) => () => api.album(a.id)
151 : undefined}
152 isTrack={type === "song"}
153 track={type === "song" ? item : null}
154 type={type as any}
155 {item}
156 />
157 {/each}
158 </ul>
159 </li>
160 {/if}
161 {/each}
162 {:else}
163 <li class="section">
164 <button
165 tabindex="-1"
166 id="lib-artists"
167 class="label"
168 class:focused={lib.focusedId === "lib-artists"}
169 data-id="lib-artists"
170 onclick={(e) => {
171 e.stopPropagation();
172 isArtistsExpanded = !isArtistsExpanded;
173 }}
174 >
175 {t("artists")}
176 </button>
177 {#if isArtistsExpanded}
178 <ul>
179 {#each lib.artists as item, itemIndex (item.id)}
180 <TreeItem
181 id={item.id}
182 uid={`artist-${item.id}-${itemIndex}`}
183 label={item.name}
184 fetcher={() => api.artist(item.id)}
185 childFactory={(a: any) => () => api.album(a.id)}
186 type="artist"
187 {item}
188 />
189 {/each}
190 </ul>
191 {/if}
192 </li>
193 <li class="section">
194 <button
195 tabindex="-1"
196 id="lib-playlists"
197 class="label"
198 class:focused={lib.focusedId === "lib-playlists"}
199 data-id="lib-playlists"
200 onclick={(e) => {
201 e.stopPropagation();
202 isPlaylistsExpanded = !isPlaylistsExpanded;
203 }}
204 >
205 {t("playlists")}
206 </button>
207 {#if isPlaylistsExpanded}
208 <ul>
209 {#each myPlaylists as item, itemIndex (item.id)}
210 <TreeItem
211 id={item.id}
212 uid={`playlist-${item.id}-${itemIndex}`}
213 label={item.name}
214 fetcher={() => api.playlist(item.id)}
215 type="playlist"
216 isReadonly={item.readonly === true}
217 owner={item.owner}
218 isPublic={item.public}
219 {item}
220 />
221 {/each}
222 </ul>
223 {/if}
224 </li>
225 {#if sharedPlaylists.length}
226 <li class="section">
227 <button
228 tabindex="-1"
229 id="lib-shared-playlists"
230 class="label"
231 class:focused={lib.focusedId === "lib-shared-playlists"}
232 data-id="lib-shared-playlists"
233 onclick={(e) => {
234 e.stopPropagation();
235 isSharedPlaylistsExpanded = !isSharedPlaylistsExpanded;
236 }}
237 >
238 {t("shared_playlists")}
239 </button>
240 {#if isSharedPlaylistsExpanded}
241 <ul>
242 {#each sharedPlaylists as item, itemIndex (item.id)}
243 <TreeItem
244 id={item.id}
245 uid={`shared-playlist-${item.id}-${itemIndex}`}
246 label={item.name}
247 fetcher={() => api.playlist(item.id)}
248 type="playlist"
249 isReadonly={item.readonly === true}
250 owner={item.owner}
251 isPublic={item.public}
252 {item}
253 />
254 {/each}
255 </ul>
256 {/if}
257 </li>
258 {/if}
259 {#if lib.isSharingSupported && lib.shares.length}
260 <li class="section">
261 <button
262 tabindex="-1"
263 id="lib-shares"
264 class="label"
265 class:focused={lib.focusedId === "lib-shares"}
266 data-id="lib-shares"
267 onclick={(e) => {
268 e.stopPropagation();
269 isSharesExpanded = !isSharesExpanded;
270 }}
271 >
272 {t("shares")}
273 </button>
274 {#if isSharesExpanded}
275 <ul>
276 {#each lib.shares as item, itemIndex (item.id)}
277 <TreeItem
278 id={item.id}
279 uid={`share-${item.id}-${itemIndex}`}
280 label={item.description || item.url}
281 type="share"
282 {item}
283 />
284 {/each}
285 </ul>
286 {/if}
287 </li>
288 {/if}
289 {/if}
290 </ul>
291</nav>
292
293<style>
294 #library {
295 flex: 1;
296 overflow-y: auto;
297 padding-block: 0.5rem;
298 padding-inline: 1rem;
299 }
300 #search {
301 inline-size: 100%;
302 }
303 .tree {
304 padding: 0;
305 margin: 0;
306 }
307 ul {
308 padding: 0;
309 margin: 0;
310 list-style: none;
311 }
312 .label {
313 display: block;
314 inline-size: 100%;
315 text-align: start;
316 padding: 0;
317 padding-block-start: 0.5rem;
318 padding-block-end: 0.25rem;
319 background: none;
320 border: none;
321 border-block-end: 1px solid var(--border-text);
322 font-weight: bold;
323 }
324 .label.focused {
325 background: Highlight;
326 color: HighlightText;
327 }
328 :global(#library:not(:focus-within)) .label.focused {
329 background: GrayText;
330 color: HighlightText;
331 }
332 button {
333 color: inherit;
334 }
335 :global(body.dynamic) #search {
336 background-color: var(--bg-secondary);
337 color: var(--text);
338 border: 1px solid var(--border);
339 border-radius: 4px;
340 }
341 :global(body.dynamic) #search::placeholder {
342 color: var(--text);
343 opacity: 0.5;
344 }
345</style>