a simple web player for subsonic
tinysub.devins.page
subsonic
navidrome
javascript
1import { api, type Song, asArray, internSong } from "./client.svelte.js";
2import { player, play, stop, restoreTrack } from "./player.svelte.js";
3import { nav, ui } from "./ui.svelte.js";
4import { auth } from "./auth.svelte.js";
5import { t } from "./lang.svelte.js";
6
7export const queue = $state({
8 tracks: [] as Song[],
9 pos: -1,
10 sel: [] as number[],
11 version: 0,
12});
13
14export const select = (index: number, multi = false, range = false) => {
15 if (index < 0 || index >= queue.tracks.length) return;
16 if (range && nav.anchor !== -1) {
17 const start = Math.min(nav.anchor, index),
18 end = Math.max(nav.anchor, index);
19 const ids = Array.from({ length: end - start + 1 }, (_, k) => start + k);
20 queue.sel = multi ? [...new Set([...queue.sel, ...ids])] : ids;
21 } else if (multi) {
22 queue.sel = queue.sel.includes(index)
23 ? queue.sel.filter((i) => i !== index)
24 : [...queue.sel, index];
25 nav.anchor = index;
26 } else {
27 queue.sel = [index];
28 nav.anchor = index;
29 }
30 nav.head = index;
31};
32
33export const moveHead = (direction: number, expand = false) => {
34 if (nav.anchor === -1) return queue.tracks.length > 0 && select(0);
35 const nextIdx = nav.head + direction;
36 if (nextIdx >= 0 && nextIdx < queue.tracks.length)
37 select(nextIdx, false, expand);
38};
39
40export const reorder = (direction: -1 | 1) => {
41 if (!queue.sel.length) return;
42 const ids = [...queue.sel].sort((a, b) => a - b);
43 if (
44 (direction === -1 && ids[0] === 0) ||
45 (direction === 1 && ids[ids.length - 1] === queue.tracks.length - 1)
46 )
47 return;
48 const move = direction === -1 ? ids : ids.reverse();
49 for (const index of move) {
50 const to = index + direction;
51 [queue.tracks[index], queue.tracks[to]] = [
52 queue.tracks[to],
53 queue.tracks[index],
54 ];
55 queue.sel[queue.sel.indexOf(index)] = to;
56 if (queue.pos === index) queue.pos = to;
57 else if (queue.pos === to) queue.pos = index;
58 if (nav.anchor === index) nav.anchor = to;
59 if (nav.head === index) nav.head = to;
60 }
61 queue.version++;
62};
63
64export const clearSel = () => {
65 queue.sel = [];
66 nav.anchor = nav.head = -1;
67};
68
69export const add = (songs: Song | Song[], next = false) => {
70 const items = asArray(songs).map(internSong);
71 if (next) {
72 queue.tracks.splice(queue.pos + 1, 0, ...items);
73 } else queue.tracks.push(...items);
74 queue.version++;
75};
76
77export const remove = () => {
78 if (!queue.sel.length) return;
79
80 const playing = queue.sel.includes(queue.pos),
81 oldPos = queue.pos,
82 removedBefore = queue.sel.filter((i) => i < oldPos).length;
83
84 queue.tracks = queue.tracks.filter((_, i) => !queue.sel.includes(i));
85
86 if (playing) {
87 if (queue.tracks.length) {
88 queue.pos = Math.min(oldPos - removedBefore, queue.tracks.length - 1);
89 const track = queue.tracks[queue.pos];
90 if (!player.paused) play(track);
91 else restoreTrack(track, 0);
92 } else stop();
93 } else if (queue.pos !== -1) {
94 queue.pos = oldPos - removedBefore;
95 }
96
97 clearSel();
98 queue.version++;
99};
100
101export const goto = (index: number) => {
102 if (index >= 0 && index < queue.tracks.length) {
103 queue.pos = index;
104 play(queue.tracks[index]);
105 queue.version++;
106 } else if (player.loop && queue.tracks.length) goto(0);
107 else stop();
108};
109
110export const next = () => goto(queue.pos + 1);
111export const prev = () =>
112 queue.pos <= 0 && player.loop && queue.tracks.length
113 ? goto(queue.tracks.length - 1)
114 : goto(queue.pos - 1);
115
116export const toggleStar = async (song: Song) => {
117 const isStarred = !!song.starred;
118 try {
119 isStarred ? await api.unstar(song.id) : await api.star(song.id);
120 song.starred = isStarred ? undefined : new Date().toISOString();
121 } catch (err) {
122 console.error("Failed to toggle star:", err);
123 }
124};
125
126export const setRating = async (song: Song, rating: number) => {
127 try {
128 await api.setRating(song.id, rating);
129 song.userRating = rating;
130 } catch (err) {
131 console.error("Failed to set rating:", err);
132 }
133};
134
135export const moveSelected = (targetIndex: number) => {
136 if (!queue.sel.length) return;
137
138 const items = queue.tracks.map((t, i) => ({ t, p: i === queue.pos }));
139 const selIndices = [...queue.sel].sort((a, b) => a - b);
140 const moved = selIndices.map((i) => items[i]);
141 const remaining = items.filter((_, i) => !queue.sel.includes(i));
142
143 const removedBefore = queue.sel.filter((i) => i < targetIndex).length;
144 const actualTarget = Math.max(0, targetIndex - removedBefore);
145
146 remaining.splice(actualTarget, 0, ...moved);
147
148 queue.tracks = remaining.map((x) => x.t);
149 queue.pos = remaining.findIndex((x) => x.p);
150
151 queue.sel = Array.from({ length: moved.length }, (_, i) => actualTarget + i);
152 nav.anchor = queue.sel[0];
153 nav.head = queue.sel[queue.sel.length - 1];
154 queue.version++;
155};
156
157export const playNext = () => {
158 if (!queue.sel.length || queue.pos === -1) return;
159
160 const items = queue.tracks.map((t, i) => ({ t, p: i === queue.pos }));
161 const selIndices = [...queue.sel].sort((a, b) => a - b);
162 const moved = selIndices.map((i) => items[i]);
163 const remaining = items.filter((_, i) => !queue.sel.includes(i));
164
165 const newPos = remaining.findIndex((x) => x.p);
166 remaining.splice(newPos + 1, 0, ...moved);
167
168 queue.tracks = remaining.map((x) => x.t);
169 queue.pos = remaining.findIndex((x) => x.p);
170 queue.sel = moved.map((_, i) => newPos + 1 + i);
171 queue.version++;
172};
173
174export const moveUp = () => reorder(-1);
175export const moveDown = () => reorder(1);
176
177export const sortQueue = (field: string, asc = true) => {
178 const isAll = !queue.sel.length;
179 const indices = isAll
180 ? queue.tracks.map((_, i) => i)
181 : [...queue.sel].sort((a, b) => a - b);
182
183 const targets = indices.map((idx) => ({
184 t: queue.tracks[idx],
185 p: idx === queue.pos,
186 }));
187
188 const cmp = (a: any, b: any) =>
189 (a || "").toString().localeCompare((b || "").toString(), undefined, {
190 numeric: true,
191 });
192
193 const trackCmp = (a: Song, b: Song) =>
194 a.discNumber !== b.discNumber
195 ? (a.discNumber || 0) - (b.discNumber || 0)
196 : (a.track || 0) - (b.track || 0);
197
198 targets.sort((a, b) => {
199 const s1 = a.t,
200 s2 = b.t;
201 let res = 0;
202 if (field === "artist") {
203 res =
204 cmp(s1.artist, s2.artist) ||
205 cmp(s1.album, s2.album) ||
206 trackCmp(s1, s2);
207 } else if (field === "album") {
208 res = cmp(s1.album, s2.album) || trackCmp(s1, s2);
209 } else {
210 const val = (s: Song) => {
211 if (field === "stars") return !!s.starred ? 1 : 0;
212 if (field === "rating") return s.userRating || 0;
213 if (field === "duration") return s.duration || 0;
214 return (s as any)[field];
215 };
216 const av = val(s1),
217 bv = val(s2);
218 res = typeof av === "number" ? av - bv : cmp(av, bv);
219 }
220 return asc ? res : -res;
221 });
222
223 indices.forEach((idx, i) => {
224 queue.tracks[idx] = targets[i].t;
225 if (targets[i].p) queue.pos = idx;
226 });
227
228 queue.version++;
229};
230
231export const starSelected = (star: boolean) =>
232 queue.sel.forEach(
233 (i) => !!queue.tracks[i].starred !== star && toggleStar(queue.tracks[i]),
234 );
235
236export const rateSelected = (rating: number) =>
237 queue.sel.forEach((i) => setRating(queue.tracks[i], rating));
238
239export const selectAll = () => {
240 queue.sel = queue.tracks.map((_, i) => i);
241 nav.anchor = 0;
242 nav.head = queue.tracks.length - 1;
243};
244
245export const clear = () => {
246 if (queue.sel.length) return remove();
247 queue.tracks = [];
248 stop();
249 clearSel();
250 queue.version++;
251};
252
253export const shuffle = () => {
254 const isAll = !queue.sel.length;
255 const indices = isAll
256 ? queue.tracks.map((_, i) => i)
257 : [...queue.sel].sort((a, b) => a - b);
258
259 const items = indices.map((idx) => ({
260 t: queue.tracks[idx],
261 p: idx === queue.pos,
262 }));
263
264 for (let i = items.length - 1; i > 0; i--) {
265 const j = Math.floor(Math.random() * (i + 1));
266 [items[i], items[j]] = [items[j], items[i]];
267 }
268
269 indices.forEach((idx, i) => {
270 queue.tracks[idx] = items[i].t;
271 if (items[i].p) queue.pos = idx;
272 });
273
274 queue.version++;
275};
276
277export const getSortItems = () => [
278 { label: t("shuffle"), action: shuffle },
279 { label: t("title_az"), action: () => sortQueue("title", true) },
280 { label: t("title_za"), action: () => sortQueue("title", false) },
281 { label: t("artist_az"), action: () => sortQueue("artist", true) },
282 { label: t("artist_za"), action: () => sortQueue("artist", false) },
283 { label: t("album_az"), action: () => sortQueue("album", true) },
284 { label: t("album_za"), action: () => sortQueue("album", false) },
285 {
286 label: t("fav_first"),
287 action: () => sortQueue("stars", false),
288 },
289 {
290 label: t("fav_last"),
291 action: () => sortQueue("stars", true),
292 },
293 {
294 label: t("rating_high"),
295 action: () => sortQueue("rating", false),
296 },
297 {
298 label: t("rating_low"),
299 action: () => sortQueue("rating", true),
300 },
301 {
302 label: t("time_long"),
303 action: () => sortQueue("duration", false),
304 },
305 {
306 label: t("time_short"),
307 action: () => sortQueue("duration", true),
308 },
309];
310
311export const syncQueue = async () => {
312 try {
313 const saved = (await api.getPlayQueue()).playQueue;
314 if (saved) {
315 const entries = asArray(saved.entry).map(internSong);
316 queue.tracks = entries;
317 if (saved.current) {
318 const idx = queue.tracks.findIndex((t) => t.id === saved.current);
319 if (idx !== -1) {
320 queue.pos = idx;
321 if (saved.position)
322 restoreTrack(queue.tracks[idx], saved.position / 1000);
323 else {
324 player.track = queue.tracks[idx];
325 player.audio.src = api.stream(player.track.id);
326 }
327 }
328 }
329 }
330 } catch (e) {
331 console.error("Failed to fetch play queue", e);
332 }
333};
334
335$effect.root(() => {
336 let timer: any;
337 $effect(() => {
338 queue.version;
339 if (ui.busy || !auth.ok) return;
340 clearTimeout(timer);
341 timer = setTimeout(() => {
342 if (!queue.tracks.length) return api.savePlayQueue([]);
343 api.savePlayQueue(
344 queue.tracks.map((t) => t.id),
345 player.track?.id || queue.tracks[0].id,
346 Math.floor(player.time * 1000),
347 );
348 }, 1000);
349 });
350});