import { 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(length); const oldToNew = new Array(length); const tracks = new Array(length); const keys = new Array(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 ).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 | 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);