import { api, type Song, asArray, internSong } from "./client.svelte.js"; import { player, play, stop, restoreTrack } from "./player.svelte.js"; import { nav, ui } from "./ui.svelte.js"; import { auth } from "./auth.svelte.js"; import { t } from "./lang.svelte.js"; export const queue = $state({ tracks: [] as Song[], pos: -1, sel: [] as number[], version: 0, }); export const select = (index: number, multi = false, range = false) => { if (index < 0 || index >= queue.tracks.length) return; if (range && nav.anchor !== -1) { const start = Math.min(nav.anchor, index), end = Math.max(nav.anchor, index); const ids = Array.from({ length: end - start + 1 }, (_, k) => start + k); queue.sel = multi ? [...new Set([...queue.sel, ...ids])] : ids; } else if (multi) { queue.sel = queue.sel.includes(index) ? queue.sel.filter((i) => i !== index) : [...queue.sel, index]; nav.anchor = index; } else { queue.sel = [index]; nav.anchor = index; } nav.head = index; }; export const moveHead = (direction: number, expand = false) => { if (nav.anchor === -1) return queue.tracks.length > 0 && select(0); const nextIdx = nav.head + direction; if (nextIdx >= 0 && nextIdx < queue.tracks.length) select(nextIdx, false, expand); }; export const reorder = (direction: -1 | 1) => { if (!queue.sel.length) return; const ids = [...queue.sel].sort((a, b) => a - b); if ( (direction === -1 && ids[0] === 0) || (direction === 1 && ids[ids.length - 1] === queue.tracks.length - 1) ) return; const move = direction === -1 ? ids : ids.reverse(); for (const index of move) { const to = index + direction; [queue.tracks[index], queue.tracks[to]] = [ queue.tracks[to], queue.tracks[index], ]; queue.sel[queue.sel.indexOf(index)] = to; if (queue.pos === index) queue.pos = to; else if (queue.pos === to) queue.pos = index; if (nav.anchor === index) nav.anchor = to; if (nav.head === index) nav.head = to; } queue.version++; }; export const clearSel = () => { queue.sel = []; nav.anchor = nav.head = -1; }; export const add = (songs: Song | Song[], next = false) => { const items = asArray(songs).map(internSong); if (next) { queue.tracks.splice(queue.pos + 1, 0, ...items); } else queue.tracks.push(...items); queue.version++; }; export const remove = () => { if (!queue.sel.length) return; const playing = queue.sel.includes(queue.pos), oldPos = queue.pos, removedBefore = queue.sel.filter((i) => i < oldPos).length; queue.tracks = queue.tracks.filter((_, i) => !queue.sel.includes(i)); if (playing) { if (queue.tracks.length) { queue.pos = Math.min(oldPos - removedBefore, queue.tracks.length - 1); const track = queue.tracks[queue.pos]; if (!player.paused) play(track); else restoreTrack(track, 0); } else stop(); } else if (queue.pos !== -1) { queue.pos = oldPos - removedBefore; } clearSel(); queue.version++; }; export const goto = (index: number) => { if (index >= 0 && index < queue.tracks.length) { queue.pos = index; play(queue.tracks[index]); queue.version++; } else if (player.loop && queue.tracks.length) goto(0); else stop(); }; export const next = () => goto(queue.pos + 1); export const prev = () => queue.pos <= 0 && player.loop && queue.tracks.length ? goto(queue.tracks.length - 1) : goto(queue.pos - 1); export const toggleStar = async (song: Song) => { const isStarred = !!song.starred; try { isStarred ? await api.unstar(song.id) : await api.star(song.id); song.starred = isStarred ? undefined : new Date().toISOString(); } catch (err) { console.error("Failed to toggle star:", err); } }; export const setRating = async (song: Song, rating: number) => { try { await api.setRating(song.id, rating); song.userRating = rating; } catch (err) { console.error("Failed to set rating:", err); } }; export const moveSelected = (targetIndex: number) => { if (!queue.sel.length) return; const items = queue.tracks.map((t, i) => ({ t, p: i === queue.pos })); const selIndices = [...queue.sel].sort((a, b) => a - b); const moved = selIndices.map((i) => items[i]); const remaining = items.filter((_, i) => !queue.sel.includes(i)); const removedBefore = queue.sel.filter((i) => i < targetIndex).length; const actualTarget = Math.max(0, targetIndex - removedBefore); remaining.splice(actualTarget, 0, ...moved); queue.tracks = remaining.map((x) => x.t); queue.pos = remaining.findIndex((x) => x.p); queue.sel = Array.from({ length: moved.length }, (_, i) => actualTarget + i); nav.anchor = queue.sel[0]; nav.head = queue.sel[queue.sel.length - 1]; queue.version++; }; export const playNext = () => { if (!queue.sel.length || queue.pos === -1) return; const items = queue.tracks.map((t, i) => ({ t, p: i === queue.pos })); const selIndices = [...queue.sel].sort((a, b) => a - b); const moved = selIndices.map((i) => items[i]); const remaining = items.filter((_, i) => !queue.sel.includes(i)); const newPos = remaining.findIndex((x) => x.p); remaining.splice(newPos + 1, 0, ...moved); queue.tracks = remaining.map((x) => x.t); queue.pos = remaining.findIndex((x) => x.p); queue.sel = moved.map((_, i) => newPos + 1 + i); queue.version++; }; export const moveUp = () => reorder(-1); export const moveDown = () => reorder(1); export const sortQueue = (field: string, asc = true) => { const isAll = !queue.sel.length; const indices = isAll ? queue.tracks.map((_, i) => i) : [...queue.sel].sort((a, b) => a - b); const targets = indices.map((idx) => ({ t: queue.tracks[idx], p: idx === queue.pos, })); const cmp = (a: any, b: any) => (a || "").toString().localeCompare((b || "").toString(), undefined, { numeric: true, }); const trackCmp = (a: Song, b: Song) => a.discNumber !== b.discNumber ? (a.discNumber || 0) - (b.discNumber || 0) : (a.track || 0) - (b.track || 0); targets.sort((a, b) => { const s1 = a.t, s2 = b.t; let res = 0; if (field === "artist") { res = cmp(s1.artist, s2.artist) || cmp(s1.album, s2.album) || trackCmp(s1, s2); } else if (field === "album") { res = cmp(s1.album, s2.album) || trackCmp(s1, s2); } else { const val = (s: Song) => { if (field === "stars") return !!s.starred ? 1 : 0; if (field === "rating") return s.userRating || 0; if (field === "duration") return s.duration || 0; return (s as any)[field]; }; const av = val(s1), bv = val(s2); res = typeof av === "number" ? av - bv : cmp(av, bv); } return asc ? res : -res; }); indices.forEach((idx, i) => { queue.tracks[idx] = targets[i].t; if (targets[i].p) queue.pos = idx; }); queue.version++; }; export const starSelected = (star: boolean) => queue.sel.forEach( (i) => !!queue.tracks[i].starred !== star && toggleStar(queue.tracks[i]), ); export const rateSelected = (rating: number) => queue.sel.forEach((i) => setRating(queue.tracks[i], rating)); export const selectAll = () => { queue.sel = queue.tracks.map((_, i) => i); nav.anchor = 0; nav.head = queue.tracks.length - 1; }; export const clear = () => { if (queue.sel.length) return remove(); queue.tracks = []; stop(); clearSel(); queue.version++; }; export const shuffle = () => { const isAll = !queue.sel.length; const indices = isAll ? queue.tracks.map((_, i) => i) : [...queue.sel].sort((a, b) => a - b); const items = indices.map((idx) => ({ t: queue.tracks[idx], p: idx === queue.pos, })); for (let i = items.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [items[i], items[j]] = [items[j], items[i]]; } indices.forEach((idx, i) => { queue.tracks[idx] = items[i].t; if (items[i].p) queue.pos = idx; }); queue.version++; }; export const getSortItems = () => [ { label: t("shuffle"), action: shuffle }, { label: t("title_az"), action: () => sortQueue("title", true) }, { label: t("title_za"), action: () => sortQueue("title", false) }, { label: t("artist_az"), action: () => sortQueue("artist", true) }, { label: t("artist_za"), action: () => sortQueue("artist", false) }, { label: t("album_az"), action: () => sortQueue("album", true) }, { label: t("album_za"), action: () => sortQueue("album", false) }, { label: t("fav_first"), action: () => sortQueue("stars", false), }, { label: t("fav_last"), action: () => sortQueue("stars", true), }, { label: t("rating_high"), action: () => sortQueue("rating", false), }, { label: t("rating_low"), action: () => sortQueue("rating", true), }, { label: t("time_long"), action: () => sortQueue("duration", false), }, { label: t("time_short"), action: () => sortQueue("duration", true), }, ]; export const syncQueue = async () => { try { const saved = (await api.getPlayQueue()).playQueue; if (saved) { const entries = asArray(saved.entry).map(internSong); queue.tracks = entries; if (saved.current) { const idx = queue.tracks.findIndex((t) => t.id === saved.current); if (idx !== -1) { queue.pos = idx; if (saved.position) restoreTrack(queue.tracks[idx], saved.position / 1000); else { player.track = queue.tracks[idx]; player.audio.src = api.stream(player.track.id); } } } } } catch (e) { console.error("Failed to fetch play queue", e); } }; $effect.root(() => { let timer: any; $effect(() => { queue.version; if (ui.busy || !auth.ok) return; clearTimeout(timer); timer = setTimeout(() => { if (!queue.tracks.length) return api.savePlayQueue([]); api.savePlayQueue( queue.tracks.map((t) => t.id), player.track?.id || queue.tracks[0].id, Math.floor(player.time * 1000), ); }, 1000); }); });