Something went wrong. Try again.
a simple but full-featured web player for OpenSubsonic music servers (Navidrome, Gonic, Ampache, etc) devins.page/tinysub
svelte typescript javascript subsonic gonic navidrome
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925import { auth } from "./auth.svelte.js";import { asArray, canPlayTrack, getSourceApi, internSongBatch, isRadio, type Song, songAlbumArtist, songArtist,} from "./client.svelte.js";import { t } from "./languages/translate.svelte.js";import { offlineManager } from "./offline/offlineManager.svelte.js";import { load, play, player, stop } from "./player.svelte.js";import { settings } from "./settings.svelte.js";import { getSource, sourceDisplayName, sources } from "./sources.svelte.js";import { nav, ui } from "./ui.svelte.js";
export type QueueMove = { key: number; from: number; to: number; fullDistance?: true;};
export const queue = $state({ tracks: [] as Song[], keys: [] as number[], pos: -1, sel: [] as number[], clipboard: [] as Song[], scrollRequest: null as { index: number; center: boolean; smooth: boolean; } | null, moveRequest: null as QueueMove[] | null, entryRequest: null as number[] | null, contentVersion: 0,});
const selectedIndices = $derived(new Set(queue.sel));export const isSelected = (index: number) => selectedIndices.has(index);
const getMoves = (indices: number[], keys: number[]) => { const moves: QueueMove[] = []; for (let to = 0; to < indices.length; to++) { const from = indices[to]; if (from !== to) moves.push({ key: keys[to], from, to }); } return moves;};
const requestMoves = (moves: QueueMove[]) => { if (moves.length) queue.moveRequest = [...(queue.moveRequest ?? []), ...moves];};
const requestReorderedMoves = (previousKeys: number[]) => { const positions = new Map(previousKeys.map((key, index) => [key, index])); requestMoves( queue.keys.flatMap((key, to) => { const from = positions.get(key); return from === undefined || from === to ? [] : [{ key, from, to, fullDistance: true }]; }), );};
const requestEntries = (keys: number[]) => { if (keys.length) queue.entryRequest = [...(queue.entryRequest ?? []), ...keys];};
export function requestQueueScroll( index: number, options: { center?: boolean; smooth?: boolean } = {},) { queue.scrollRequest = { index, center: options.center ?? false, smooth: options.smooth ?? false, };}
type HistoryState = { tracks: Song[]; pos: number; keys: number[] };
const history: HistoryState[] = [];let historyIndex = -1;let nextTrackKey = 0;
export const copy = () => { if (queue.sel.length) queue.clipboard = queue.sel.map((index) => queue.tracks[index]);};
export const cut = () => { if (!queue.sel.length) return; copy(); remove();};
export const insertAt = ( index: number, songs: Song | Song[], selectInserted = false,) => { const items = internSongBatch(asArray(songs)); const entryKeys = items.map(() => nextTrackKey++); const moves = queue.tracks.slice(index).map((_, offset) => ({ key: queue.keys[index + offset], from: index + offset, to: index + items.length + offset, })); queue.tracks.splice(index, 0, ...items); queue.keys.splice(index, 0, ...entryKeys); queue.contentVersion++; requestMoves(moves); requestEntries(entryKeys); if (queue.pos >= index) queue.pos += items.length; if (selectInserted) { queue.sel = Array.from({ length: items.length }, (_, i) => index + i); nav.anchor = queue.sel[0]; nav.head = queue.sel[queue.sel.length - 1]; }};
export const paste = (index?: number) => { if (!queue.clipboard.length) return; const target = index ?? (queue.sel.length ? queue.sel.reduce((max, selected) => Math.max(max, selected)) + 1 : queue.tracks.length); insertAt(target, queue.clipboard, true);};
const record = () => { const { tracks, pos } = queue; const previous = history[historyIndex]; if ( previous && previous.tracks.length === tracks.length && previous.tracks.every((track, i) => track === tracks[i]) && previous.keys.every((key, i) => key === queue.keys[i]) ) { previous.pos = pos; return; }
history.splice(historyIndex + 1); history.push({ tracks: [...tracks], pos, keys: [...queue.keys] }); if (history.length > 50) history.shift(); else historyIndex++;};
const restoreHistory = (state: HistoryState, previous: HistoryState) => { const positions = new Map(previous.keys.map((key, index) => [key, index])); queue.tracks = [...state.tracks]; queue.keys = [...state.keys]; const entries: number[] = []; requestMoves( state.keys.flatMap((key, to) => { const from = positions.get(key); if (from === undefined) { entries.push(key); return []; } return from === to ? [] : [{ key, from, to }]; }), ); requestEntries(entries); if (entries.length || state.keys.length !== previous.keys.length) queue.contentVersion++; queue.pos = state.pos; clearSel();};
export const undo = () => { if (historyIndex <= 0) return; const previous = history[historyIndex]; restoreHistory(history[--historyIndex], previous);};
export const redo = () => { if (historyIndex >= history.length - 1) return; const previous = history[historyIndex]; restoreHistory(history[++historyIndex], previous);};
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 }, (_, offset) => start + offset, ); queue.sel = multi ? [...new Set([...queue.sel, ...ids])] : ids; } else if (multi) { queue.sel = selectedIndices.has(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 toggleSelectionRange = (from: number, to: number) => { const start = Math.max(0, Math.min(from, to)); const end = Math.min(queue.tracks.length - 1, Math.max(from, to)); if (start > end) return; const selection = new Set(queue.sel); for (let index = start; index <= end; index++) { if (selection.has(index)) selection.delete(index); else selection.add(index); } queue.sel = [...selection]; nav.anchor = nav.head = end;};
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, steps = 1): QueueMove[] => { if (!queue.sel.length || steps <= 0) return []; const selected = [...queue.sel].sort((a, b) => a - b); const distance = direction === -1 ? selected[0] : queue.tracks.length - 1 - selected[selected.length - 1]; const stepCount = Math.min(Math.ceil(steps), distance); if (!stepCount) return [];
if (stepCount === 1) { const moves: QueueMove[] = []; if (direction === 1) selected.reverse(); for (const from of selected) { const to = from + direction; const movedKey = queue.keys[from]; const displacedKey = queue.keys[to]; [queue.tracks[from], queue.tracks[to]] = [ queue.tracks[to], queue.tracks[from], ]; [queue.keys[from], queue.keys[to]] = [displacedKey, movedKey]; moves.push( { key: movedKey, from, to }, { key: displacedKey, from: to, to: from }, ); if (queue.pos === from) queue.pos = to; else if (queue.pos === to) queue.pos = from; } if (selectedIndices.has(nav.anchor)) nav.anchor += direction; if (selectedIndices.has(nav.head)) nav.head += direction; queue.sel = queue.sel.map((index) => index + direction); return moves; } const shift = direction * stepCount;
const length = queue.tracks.length; const positions = new Array<number>(length); const oldToNew = new Array<number>(length); const tracks = new Array<Song>(length); const keys = new Array<number>(length); let selectedCursor = 0; let unselectedFrom = 0; for (let to = 0; to < length; to++) { const selectedFrom = selected[selectedCursor]; let from: number; if (selectedFrom !== undefined && to === selectedFrom + shift) { from = selectedFrom; selectedCursor++; } else { while (selectedIndices.has(unselectedFrom)) unselectedFrom++; from = unselectedFrom++; } positions[to] = from; oldToNew[from] = to; tracks[to] = queue.tracks[from]; keys[to] = queue.keys[from]; }
queue.tracks = tracks; queue.keys = keys; if (queue.pos !== -1) queue.pos = oldToNew[queue.pos]; if (selectedIndices.has(nav.anchor)) nav.anchor += shift; if (selectedIndices.has(nav.head)) nav.head += shift; queue.sel = queue.sel.map((index) => oldToNew[index]); return getMoves(positions, keys);};
export const clearSel = () => { queue.sel = []; nav.anchor = nav.head = -1;};
export const remove = () => { if (!queue.sel.length) return;
const playing = selectedIndices.has(queue.pos), oldPos = queue.pos, removedBefore = queue.sel.filter((index) => index < oldPos).length;
const positions = queue.tracks .map((_, index) => index) .filter((index) => !selectedIndices.has(index)); queue.tracks = queue.tracks.filter((_, index) => !selectedIndices.has(index)); queue.keys = queue.keys.filter((_, index) => !selectedIndices.has(index)); queue.contentVersion++; requestMoves(getMoves(positions, queue.keys));
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 load(track, 0); } else stop(); } else if (queue.pos !== -1) { queue.pos = oldPos - removedBefore; }
clearSel();};
const scanTarget = (index: number, direction: 1 | -1 = 1): number => { if ( queue.tracks.length > 0 && queue.tracks.some((track) => !canPlayTrack(track)) ) { const scan = (start: number) => { for (let i = start; i >= 0 && i < queue.tracks.length; i += direction) { if (canPlayTrack(queue.tracks[i])) return i; } return -1; };
const target = scan(index); if (target === -1 && player.loop) { return scan(direction === 1 ? 0 : queue.tracks.length - 1); } return target; }
if (index >= 0 && index < queue.tracks.length) return index; if (player.loop && queue.tracks.length) { return direction === 1 ? 0 : queue.tracks.length - 1; } return -1;};
export const goto = (index: number, direction: 1 | -1 = 1) => { const target = scanTarget(index, direction); if (target === -1) { stop(); return; } if ( target !== index && queue.tracks[index] && !canPlayTrack(queue.tracks[index]) ) { requestQueueScroll(target); } queue.pos = target; play(queue.tracks[target]);};
export const nextTarget = (): number => scanTarget(queue.pos + 1, 1);
export const next = () => goto(queue.pos + 1, 1);export const prev = () => goto(queue.pos - 1, -1);
export const toggleStar = async (song: Song) => { const isStarred = !!song.starred; try { const sourceId = song.sourceId || sources.activeId; if (!sourceId) return; const sourceApi = getSourceApi(sourceId); isStarred ? await sourceApi.unstar({ id: song.id }) : await sourceApi.star({ id: song.id }); song.starred = isStarred ? undefined : new Date().toISOString(); } catch (err) { console.error(err); }};
export const setRating = async (song: Song, rating: number) => { try { const sourceId = song.sourceId || sources.activeId; if (!sourceId) return; await getSourceApi(sourceId).setRating(song.id, rating); song.userRating = rating; } catch (err) { console.error("failed to set rating:", err); }};
export const moveSelected = (targetIndex: number): QueueMove[] => { if (!queue.sel.length) return [];
const moved = queue.sel .sort((a, b) => a - b) .map((index) => queue.tracks[index]); const movedKeys = queue.sel.map((index) => queue.keys[index]); const remaining = queue.tracks.filter( (_, index) => !selectedIndices.has(index), ); const remainingKeys = queue.keys.filter( (_, index) => !selectedIndices.has(index), );
const actualTarget = Math.max( 0, targetIndex - queue.sel.filter((index) => index < targetIndex).length, );
const indices = Array.from({ length: queue.tracks.length }, (_, i) => i); const remainingIndices = indices.filter((i) => !selectedIndices.has(i)); remainingIndices.splice(actualTarget, 0, ...queue.sel);
remaining.splice(actualTarget, 0, ...moved); remainingKeys.splice(actualTarget, 0, ...movedKeys);
queue.tracks = remaining; queue.keys = remainingKeys; queue.pos = queue.pos !== -1 ? remainingIndices.indexOf(queue.pos) : -1; queue.sel = Array.from({ length: moved.length }, (_, i) => actualTarget + i); nav.anchor = queue.sel[0]; nav.head = queue.sel[queue.sel.length - 1]; return getMoves(remainingIndices, queue.keys);};
export const playNext = () => { if (!queue.sel.length || queue.pos === -1) return; requestMoves(moveSelected(queue.pos + 1));};
type SortField = | "album" | "albumArtist" | "artist" | "source" | "downloaded" | "duration" | "genre" | "playCount" | "rating" | "stars" | "title" | "year";
const sortCollator = new Intl.Collator(undefined, { numeric: true });
const sortQueue = (field: SortField, asc = true) => { const isAll = !queue.sel.length; const playingIndex = queue.pos; const indices = isAll ? queue.tracks.map((_, i) => i) : [...queue.sel].sort((a, b) => a - b);
const targets = indices.map((from) => { const track = queue.tracks[from]; const albumArtist = (songAlbumArtist(track) ?? "").toString(); const artist = (songArtist(track) ?? "").toString(); const album = (track.album ?? "").toString(); const title = (track.title ?? "").toString(); const source = sourceDisplayName(getSource(track.sourceId)); let value: string | number; if (field === "source") value = source; else if (field === "albumArtist") value = albumArtist; else if (field === "artist") value = artist; else if (field === "genre") value = (track.genres?.[0]?.name || track.genre || "").toString(); else if (field === "stars") value = track.starred ? 1 : 0; else if (field === "rating") value = track.userRating || 0; else if (field === "duration") value = track.duration || 0; else if (field === "year") value = track.year || 0; else if (field === "playCount") value = track.playCount || 0; else if (field === "downloaded") value = offlineManager.isSongDownloaded(track) ? 1 : 0; else value = field === "album" ? album : title; return { track, key: queue.keys[from], from, value, albumArtist, source, hasAlbumArtist: Boolean( track.displayAlbumArtist || track.albumArtist || track.albumArtists?.length, ), artist, album, discNumber: track.discNumber, trackNumber: track.track || 0, title, }; });
const compare = sortCollator.compare; const compareTrackNumbers = ( a: (typeof targets)[number], b: (typeof targets)[number], ) => a.discNumber !== b.discNumber ? (a.discNumber || 0) - (b.discNumber || 0) : a.trackNumber - b.trackNumber; const compareTieBreakers = ( a: (typeof targets)[number], b: (typeof targets)[number], ) => compare(a.source, b.source) || compare(a.albumArtist, b.albumArtist) || (!a.hasAlbumArtist || !b.hasAlbumArtist ? compare(a.artist, b.artist) : 0) || compare(a.album, b.album) || compareTrackNumbers(a, b) || compare(a.title, b.title);
targets.sort((a, b) => { const primaryResult = typeof a.value === "number" && typeof b.value === "number" ? a.value - b.value : compare(a.value as string, b.value as string); if (primaryResult) return asc ? primaryResult : -primaryResult; return compareTieBreakers(a, b); });
const moves: QueueMove[] = []; for (let i = 0; i < indices.length; i++) { const index = indices[i]; const target = targets[i]; if (target.from === index) continue; queue.tracks[index] = target.track; queue.keys[index] = target.key; if (target.from === playingIndex) queue.pos = index; moves.push({ key: target.key, from: target.from, to: index, fullDistance: true, }); }
requestMoves(moves); if (isAll) clearSel();};
export const starSelected = (star: boolean) => queue.sel.forEach((index) => { const track = queue.tracks[index]; if (!track || isRadio(track)) return; if (Boolean(track.starred) !== star) toggleStar(track); });
export const rateSelected = (rating: number) => queue.sel.forEach((index) => { const track = queue.tracks[index]; if (!track || isRadio(track)) return; setRating(track, 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 = []; queue.keys = []; queue.contentVersion++; stop(); clearSel();};
export const removeSourceTracks = (sourceId: string) => { const currentTrack = queue.tracks[queue.pos]; const currentKey = queue.keys[queue.pos]; queue.clipboard = queue.clipboard.filter( (track) => track.sourceId !== sourceId, ); history.length = 0; historyIndex = -1; const retainedIndices = queue.tracks.flatMap((track, index) => track.sourceId === sourceId ? [] : [index], ); if (retainedIndices.length === queue.tracks.length) return; const remaining = retainedIndices.map((index) => queue.tracks[index]); const remainingKeys = retainedIndices.map((index) => queue.keys[index]); queue.tracks = remaining; queue.keys = remainingKeys; if (currentTrack?.sourceId === sourceId) { queue.pos = Math.min(queue.pos, queue.tracks.length - 1); if (player.track?.sourceId === sourceId) stop(); } else { queue.pos = currentKey === undefined ? -1 : remainingKeys.indexOf(currentKey); } queue.contentVersion++; clearSel();};
const shuffle = () => { const previousKeys = [...queue.keys]; const isAll = !queue.sel.length; const indices = isAll ? queue.tracks.map((_, i) => i) : [...queue.sel].sort((a, b) => a - b);
const items = indices.map((index) => ({ track: queue.tracks[index], key: queue.keys[index], playing: index === 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((index, i) => { queue.tracks[index] = items[i].track; queue.keys[index] = items[i].key; if (items[i].playing) queue.pos = index; });
requestReorderedMoves(previousKeys); if (isAll) { clearSel(); }};
type SortItem = { label?: string; action?: () => void; type?: "separator"; items?: Array<{ label?: string; action?: () => void }>; disabled?: boolean;};
export const getSortItems = (): SortItem[] => ( [ { label: t("sort.shuffle"), action: shuffle }, { label: t("sort.title_az"), action: () => sortQueue("title", true) }, { label: t("sort.title_za"), action: () => sortQueue("title", false) }, { label: t("sort.album_artist_az"), action: () => sortQueue("albumArtist", true), }, { label: t("sort.album_artist_za"), action: () => sortQueue("albumArtist", false), }, { label: t("sort.artist_az"), action: () => sortQueue("artist", true) }, { label: t("sort.artist_za"), action: () => sortQueue("artist", false) }, { label: t("sort.genre_az"), action: () => sortQueue("genre", true) }, { label: t("sort.genre_za"), action: () => sortQueue("genre", false) }, { label: t("sort.album_az"), action: () => sortQueue("album", true) }, { label: t("sort.album_za"), action: () => sortQueue("album", false) }, sources.items.length > 1 && { label: t("sort.server_az"), action: () => sortQueue("source", true), }, sources.items.length > 1 && { label: t("sort.server_za"), action: () => sortQueue("source", false), }, { label: t("sort.fav_first"), action: () => sortQueue("stars", false) }, { label: t("sort.fav_last"), action: () => sortQueue("stars", true) }, { label: t("sort.rating_high"), action: () => sortQueue("rating", false), }, { label: t("sort.rating_low"), action: () => sortQueue("rating", true) }, { label: t("sort.time_long"), action: () => sortQueue("duration", false), }, { label: t("sort.time_short"), action: () => sortQueue("duration", true), }, { label: t("sort.year_new"), action: () => sortQueue("year", false) }, { label: t("sort.year_old"), action: () => sortQueue("year", true) }, { label: t("sort.play_count_high"), action: () => sortQueue("playCount", false), }, { label: t("sort.play_count_low"), action: () => sortQueue("playCount", true), }, settings.downloads && { label: t("sort.downloaded_first"), action: () => sortQueue("downloaded", false), }, settings.downloads && { label: t("sort.downloaded_last"), action: () => sortQueue("downloaded", true), }, ] as Array<SortItem | false> ).filter((x): x is SortItem => x !== false);
export const syncQueue = async (sourceId = sources.activeId) => { if (queue.tracks.length) return; if (!sourceId) return; const contentVersion = queue.contentVersion; try { const saved = (await getSourceApi(sourceId).getPlayQueue()).playQueue; if (saved) { const entries = internSongBatch(asArray(saved.entry), sourceId); // HACK: getPlayQueue can omit album artist metadata, // so hydrate affected albums.i noticed this in Gonic // where it wasn't returning the album artist data on // startup. you should not notice this on Navidrome. const albumIds = new Set( entries.flatMap((song) => { if ( song.displayAlbumArtist || song.albumArtist || song.albumArtists?.length || !song.albumId ) return []; return [song.albumId]; }), ); await Promise.all( [...albumIds].map((albumId) => getSourceApi(sourceId) .getAlbum(albumId) .catch((err: unknown) => { console.warn( "failed to hydrate queue album metadata:", albumId, err, ); }), ), ); if ( sourceId !== sources.activeId || queue.tracks.length || queue.contentVersion !== contentVersion ) return; queue.tracks = entries; queue.keys = entries.map(() => nextTrackKey++); queue.contentVersion++; requestEntries(queue.keys); if (saved.current != null) { let idx = queue.tracks.findIndex((t) => t.id === saved.current); if (idx === -1) { const num = Number(saved.current); if (!Number.isNaN(num) && num >= 0 && num < queue.tracks.length) { idx = num; } }
if (idx !== -1) { queue.pos = idx; requestQueueScroll(idx, { center: true }); load(queue.tracks[idx], saved.position ? saved.position / 1000 : 0); } } } } catch (err) { console.error("failed to fetch play queue:", err); }};
record();
const destroyQueueEffects = $effect.root(() => { type QueueSnapshot = { sourceId: string; ids: string[]; currentIndex?: number; position?: number; signature: string; };
let saveTimer: ReturnType<typeof setTimeout> | undefined; let activeSave = ""; let destroyed = false; let pendingSave: QueueSnapshot | undefined;
const writeQueue = async (snapshot: QueueSnapshot) => { activeSave = snapshot.signature; try { await getSourceApi(snapshot.sourceId).savePlayQueue( snapshot.ids, snapshot.currentIndex, snapshot.position, ); } catch (err) { console.warn("failed to save play queue:", err); } activeSave = ""; const next = pendingSave; pendingSave = undefined; if ( !destroyed && next && !ui.busy && auth.loggedIn && !offlineManager.isSourceOffline(next.sourceId) ) void writeQueue(next); };
const saveQueue = () => { clearTimeout(saveTimer); if (ui.busy || !auth.loggedIn) return;
const current = queue.tracks[queue.pos]; const sourceIds = new Set( queue.tracks .filter((track) => !isRadio(track)) .map((track) => track.sourceId) .filter((id): id is string => !!id), ); if (sourceIds.size > 1) { pendingSave = undefined; return; } const sourceId = [...sourceIds][0] || current?.sourceId || auth.sourceId || sources.activeId; if (!sourceId || offlineManager.isSourceOffline(sourceId)) return; const ids: string[] = []; let precedingSaveable = 0; for (let index = 0; index < queue.tracks.length; index++) { const track = queue.tracks[index]; if (isRadio(track)) continue; ids.push(track.id); if (index < queue.pos) precedingSaveable++; } const currentIndex = !ids.length ? undefined : current && !isRadio(current) ? precedingSaveable : Math.max(precedingSaveable - 1, 0); const position = current && !isRadio(current) ? Math.floor(player.time * 1000) : undefined; const signature = JSON.stringify([sourceId, ids, currentIndex, position]); const snapshot = { sourceId, ids, currentIndex, position, signature }; if (!activeSave) { return void writeQueue(snapshot); } if (signature === activeSave) { pendingSave = undefined; return; } if (pendingSave?.signature !== signature) pendingSave = snapshot; };
const scheduleSave = () => { clearTimeout(saveTimer); saveTimer = setTimeout(saveQueue, 1000); };
$effect(() => { queue.pos;
record();
const track = queue.tracks[queue.pos]; if (!track) { if (player.track) stop(); } else if ( track.id !== player.track?.id || track.sourceId !== player.track?.sourceId ) { if (!player.paused) play(track); else load(track, 0); } });
$effect(() => { queue.keys.join(","); queue.pos; auth.loggedIn; ui.busy; for (const track of queue.tracks) offlineManager.isSourceOffline(track.sourceId); offlineManager.isSourceOffline(auth.sourceId || sources.activeId); scheduleSave(); });
$effect(() => { if (!player.paused || !player.track || player.loading) return; player.time; scheduleSave(); });
return () => { destroyed = true; pendingSave = undefined; clearTimeout(saveTimer); };});
if (import.meta.hot) import.meta.hot.dispose(destroyQueueEffects);