import { untrack } from "svelte"; import { auth } from "../api/auth.svelte.js"; import { api, asArray, canPlayTrack, internSongBatch, isRadio, itemSortName, setRating, songAlbumArtist, songArtist, toggleStar, } from "../api/client.svelte.js"; import type { Song } from "../api/types.js"; import { settings } from "../app/settings.svelte.js"; import type { Item } from "../components/ContextMenu.svelte"; import { t } from "../languages/translate.svelte.js"; import { foldSearch } from "../library/browse.js"; import { offlineManager } from "../offline/offlineManager.svelte.js"; import { nav, ui } from "../ui/state.svelte.js"; import { load, play, player, stop } from "./player.svelte.js"; export type QueueMove = { key: number; from: number; to: number; }; export const queue = $state({ tracks: [] as Song[], keys: [] as number[], pos: -1, sel: [] as number[], stopAfterKey: null as number | null, 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, revision: 0, }); export const queueFilter = $state({ text: "", debounced: "", }); function queueMatchesFilter(track: Song, needle: string): boolean { const artist = songArtist(track) ?? ""; const albumArtist = songAlbumArtist(track) ?? ""; const genre = track.genres?.map((g) => g.name).join(" ") || track.genre || ""; const haystack = foldSearch( `${track.title ?? ""} ${artist} ${albumArtist} ${track.album ?? ""} ${genre}`, ); return haystack.includes(needle); } export function getQueueFilterNeedle(): string { return foldSearch(queueFilter.debounced); } function computeFilteredQueueIndices(): number[] { const needle = getQueueFilterNeedle(); const tracks = queue.tracks; if (!needle) return tracks.map((_, index) => index); const indices: number[] = []; for (let index = 0; index < tracks.length; index++) { const track = tracks[index]; if (track && queueMatchesFilter(track, needle)) indices.push(index); } return indices; } const filteredIndices = $derived(computeFilteredQueueIndices()); const hasVisibleTracks = $derived(filteredIndices.length > 0); export function getVisibleQueueIndices(): number[] { return filteredIndices; } export function hasVisibleQueueTracks(): boolean { return hasVisibleTracks; } const selectedIndices = $derived(new Set(queue.sel)); export const isSelected = (index: number) => selectedIndices.has(index); export const isStopAfter = (index: number) => queue.keys[index] === queue.stopAfterKey; export const toggleStopAfter = (index: number) => { const track = queue.tracks[index]; const key = queue.keys[index]; if (key === undefined || !track || isRadio(track)) return; queue.stopAfterKey = queue.stopAfterKey === key ? null : key; }; const pruneStopAfter = () => { if (queue.stopAfterKey !== null && !queue.keys.includes(queue.stopAfterKey)) queue.stopAfterKey = null; }; const getMoves = ( indices: number[], keys: number[], visibleKeys?: Set, ) => { const moves: QueueMove[] = []; for (let to = 0; to < indices.length; to++) { const from = indices[to]; if (from !== to && (!visibleKeys || visibleKeys.has(keys[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[], visibleKeys?: Set, ) => { 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 || (visibleKeys && !visibleKeys.has(key)) ? [] : [{ key, from, to }]; }), ); }; 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[]; revision: 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 = (visibleKeys?: Set) => { if (!queue.sel.length) return; copy(); remove(visibleKeys); }; export const queueInsertIndex = (asNext: boolean) => asNext ? queue.pos + 1 : queue.tracks.length; export const insertAt = ( index: number, songs: Song | Song[], selectInserted = false, visibleKeys?: Set, ) => { const items = internSongBatch(asArray(songs)); if (!items.length) return; const entryKeys = items.map(() => nextTrackKey++); const moves: QueueMove[] = []; const tailEnd = queue.keys.length; for (let i = index; i < tailEnd; i++) { const key = queue.keys[i]; if (!visibleKeys || visibleKeys.has(key)) moves.push({ key, from: i, to: i + items.length, }); } queue.tracks.splice(index, 0, ...items); queue.keys.splice(index, 0, ...entryKeys); queue.contentVersion++; queue.revision++; 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, visibleKeys?: Set) => { 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, visibleKeys); }; const record = () => { const pos = queue.pos; const revision = queue.revision; const previous = history[historyIndex]; if (previous && previous.revision === revision) { previous.pos = pos; return; } const snapshot = untrack(() => ({ tracks: [...queue.tracks], keys: [...queue.keys], })); history.splice(historyIndex + 1); history.push({ ...snapshot, pos, revision }); 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]; queue.revision = state.revision; pruneStopAfter(); 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); let ids: number[]; if (getQueueFilterNeedle() !== "") { const visible = new Set(getVisibleQueueIndices()); ids = []; for (let i = start; i <= end; i++) if (visible.has(i)) ids.push(i); } else { 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 visible = getQueueFilterNeedle() !== "" ? new Set(getVisibleQueueIndices()) : null; const selection = new Set(queue.sel); for (let index = start; index <= end; index++) { if (visible && !visible.has(index)) continue; if (selection.has(index)) selection.delete(index); else selection.add(index); } queue.sel = [...selection]; nav.anchor = nav.head = end; }; export const moveHead = (delta: number, expand = false) => { if (!queue.tracks.length) return; if (getQueueFilterNeedle() === "") { if (nav.head === -1) return select(0); const nextIdx = Math.max( 0, Math.min(queue.tracks.length - 1, nav.head + delta), ); if (nextIdx !== nav.head) select(nextIdx, false, expand); return; } const visible = getVisibleQueueIndices(); if (!visible.length) return; let rank = 0; while (rank < visible.length && visible[rank] < nav.head) rank++; const exact = rank < visible.length && visible[rank] === nav.head; const target = exact ? rank + delta : rank + delta - (delta > 0 ? 1 : 0); const nextRank = Math.max(0, Math.min(visible.length - 1, target)); if (visible[nextRank] === nav.head) return; select(visible[nextRank], false, expand); }; export const selectEdge = (direction: -1 | 1, expand = false) => { if (getQueueFilterNeedle() === "") { select(direction === -1 ? 0 : queue.tracks.length - 1, false, expand); return; } const visible = getVisibleQueueIndices(); if (!visible.length) return; const edge = direction === -1 ? visible[0] : visible[visible.length - 1]; select(edge, false, expand && visible.includes(nav.anchor)); }; const rankSelection = (visible: number[], selected: number[]) => { const rankOf = new Map(visible.map((index, rank) => [index, rank])); const selectedRanks: number[] = []; for (const index of selected) { const rank = rankOf.get(index); if (rank !== undefined) selectedRanks.push(rank); } selectedRanks.sort((a, b) => a - b); return { rankOf, selectedRanks }; }; export const canReorder = (direction: -1 | 1): boolean => { if (!queue.sel.length) return false; if (getQueueFilterNeedle() === "") { return direction === -1 ? !isSelected(0) : !isSelected(queue.tracks.length - 1); } const visible = getVisibleQueueIndices(); if (!visible.length) return false; const { selectedRanks } = rankSelection(visible, queue.sel); if (!selectedRanks.length) return false; return direction === -1 ? selectedRanks[0] > 0 : selectedRanks[selectedRanks.length - 1] < visible.length - 1; }; const reorderVisible = ( direction: -1 | 1, steps: number, visibleIndices: number[], selected: number[], visibleKeys?: Set, ): QueueMove[] => { const { rankOf, selectedRanks } = rankSelection(visibleIndices, selected); if (!selectedRanks.length) return []; const distance = direction === -1 ? selectedRanks[0] : visibleIndices.length - 1 - selectedRanks[selectedRanks.length - 1]; const stepCount = Math.min(Math.ceil(steps), distance); if (!stepCount) return []; const shift = direction * stepCount; const selectedSet = new Set(selectedRanks); const rankOldToNew = new Array(visibleIndices.length); const tracks = [...queue.tracks]; const keys = [...queue.keys]; const moves: QueueMove[] = []; let selectedCursor = 0; let unselectedRank = 0; for (let toRank = 0; toRank < visibleIndices.length; toRank++) { const selectedFrom = selectedRanks[selectedCursor]; let fromRank: number; if (selectedFrom !== undefined && toRank === selectedFrom + shift) { fromRank = selectedFrom; selectedCursor++; } else { while (selectedSet.has(unselectedRank)) unselectedRank++; fromRank = unselectedRank++; } rankOldToNew[fromRank] = toRank; const to = visibleIndices[toRank]; const from = visibleIndices[fromRank]; tracks[to] = queue.tracks[from]; keys[to] = queue.keys[from]; if (fromRank !== toRank) { const key = keys[to]; if (!visibleKeys || visibleKeys.has(key)) moves.push({ key, from, to }); } } queue.tracks = tracks; queue.keys = keys; queue.revision++; const oldToNew = (index: number) => { const rank = rankOf.get(index); return rank === undefined ? index : visibleIndices[rankOldToNew[rank]]; }; if (queue.pos !== -1) queue.pos = oldToNew(queue.pos); if (selectedIndices.has(nav.anchor)) nav.anchor = oldToNew(nav.anchor); if (selectedIndices.has(nav.head)) nav.head = oldToNew(nav.head); queue.sel = queue.sel.map(oldToNew); return moves; }; export const reorder = ( direction: -1 | 1, steps = 1, visibleKeys?: Set, ): QueueMove[] => { if (!queue.sel.length || steps <= 0) return []; const selected = [...queue.sel].sort((a, b) => a - b); if (getQueueFilterNeedle() !== "") { return reorderVisible( direction, steps, getVisibleQueueIndices(), selected, visibleKeys, ); } 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]; if (!visibleKeys || visibleKeys.has(movedKey)) moves.push({ key: movedKey, from, to }); if (!visibleKeys || visibleKeys.has(displacedKey)) moves.push({ 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); queue.revision++; 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; queue.revision++; 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, visibleKeys); }; export const clearSel = () => { queue.sel = []; nav.anchor = nav.head = -1; }; export const remove = (visibleKeys?: Set) => { if (!queue.sel.length) return; const playing = selectedIndices.has(queue.pos), oldPos = queue.pos, removedBefore = queue.sel.filter((index) => index < oldPos).length; const positions: number[] = []; const nextTracks: Song[] = []; const nextKeys: number[] = []; const tracks = queue.tracks; const keys = queue.keys; for (let index = 0; index < tracks.length; index++) { if (selectedIndices.has(index)) continue; positions.push(index); nextTracks.push(tracks[index]); nextKeys.push(keys[index]); } queue.tracks = nextTracks; queue.keys = nextKeys; queue.contentVersion++; queue.revision++; pruneStopAfter(); requestMoves(getMoves(positions, nextKeys, visibleKeys)); 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 (offlineManager.isOfflineMode && queue.tracks.length > 0) { 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 (offlineManager.isOfflineMode && target !== 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 moveSelected = ( targetIndex: number, visibleKeys?: Set, ): QueueMove[] => { if (!queue.sel.length) return []; const selected = [...queue.sel].sort((a, b) => a - b); const tracks = queue.tracks; const keys = queue.keys; const moved = selected.map((index) => tracks[index]); const movedKeys = selected.map((index) => keys[index]); const actualTarget = Math.max( 0, targetIndex - queue.sel.filter((index) => index < targetIndex).length, ); const remaining: Song[] = []; const remainingKeys: number[] = []; const remainingIndices: number[] = []; const pos = queue.pos; let posRank = -1; for (let index = 0; index < tracks.length; index++) { if (selectedIndices.has(index)) continue; if (index === pos) posRank = remainingIndices.length; remaining.push(tracks[index]); remainingKeys.push(keys[index]); remainingIndices.push(index); } remaining.splice(actualTarget, 0, ...moved); remainingKeys.splice(actualTarget, 0, ...movedKeys); remainingIndices.splice(actualTarget, 0, ...selected); queue.tracks = remaining; queue.keys = remainingKeys; queue.revision++; if (pos === -1) { queue.pos = -1; } else if (posRank !== -1) { queue.pos = posRank + (actualTarget <= posRank ? selected.length : 0); } else { queue.pos = actualTarget + selected.indexOf(pos); } 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, remainingKeys, visibleKeys); }; export const playNext = () => { if (!queue.sel.length || queue.pos === -1) return; requestMoves(moveSelected(queue.pos + 1)); }; type SortField = | "album" | "albumArtist" | "artist" | "disc" | "downloaded" | "duration" | "genre" | "playCount" | "quality" | "rating" | "stars" | "title" | "track" | "year"; const sortCollator = new Intl.Collator(undefined, { numeric: true }); const selectedOrAllIndices = () => queue.sel.length ? [...queue.sel].sort((a, b) => a - b) : getVisibleQueueIndices(); const sortQueue = (field: SortField, asc = true, visibleKeys?: Set) => { const isAll = !queue.sel.length; const playingIndex = queue.pos; const indices = selectedOrAllIndices(); if (!indices.length) return; 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 = itemSortName(track); let value: string | number; 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 ?? track.playcount ?? 0; else if (field === "quality") value = [track.suffix, track.bitRate].filter(Boolean).join(" "); else if (field === "disc") value = track.discNumber || 0; else if (field === "track") value = track.track || 0; else if (field === "downloaded") value = offlineManager.downloadedSongIds.has(track.id) ? 1 : 0; else value = field === "album" ? album : title; return { track, key: queue.keys[from], from, value, albumArtist, hasAlbumArtist: Boolean( track.displayAlbumArtist || track.albumArtist || track.albumArtists?.length, ), artist, album, year: track.year, discNumber: track.discNumber, trackNumber: track.track || 0, title, }; }); const compare = sortCollator.compare; const compareYearFallback = ( a: (typeof targets)[number], b: (typeof targets)[number], ): number => { if (a.year === b.year) return 0; if (a.year === undefined) return 1; if (b.year === undefined) return -1; return a.year - b.year; }; 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.albumArtist, b.albumArtist) || (!a.hasAlbumArtist || !b.hasAlbumArtist ? compare(a.artist, b.artist) : 0) || compareYearFallback(a, b) || 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; if (visibleKeys && !visibleKeys.has(target.key)) continue; moves.push({ key: target.key, from: target.from, to: index, }); } requestMoves(moves); queue.revision++; if (isAll) clearSel(); }; const forEachNonRadioSelected = (apply: (track: Song) => void) => queue.sel.forEach((index) => { const track = queue.tracks[index]; if (!track || isRadio(track)) return; apply(track); }); export const starSelected = (star: boolean) => forEachNonRadioSelected((track) => { if (Boolean(track.starred) !== star) toggleStar(track, "song"); }); export const rateSelected = (rating: number) => forEachNonRadioSelected((track) => { setRating(track, rating); }); export const selectAll = () => { const indices = [...getVisibleQueueIndices()]; queue.sel = indices; nav.anchor = indices[0] ?? -1; nav.head = indices[indices.length - 1] ?? -1; }; export const clear = (visibleKeys?: Set) => { if (queue.sel.length) return remove(visibleKeys); if (getQueueFilterNeedle() !== "") { const indices = [...getVisibleQueueIndices()]; if (!indices.length) return; queue.sel = indices; return remove(visibleKeys); } queue.tracks = []; queue.keys = []; queue.contentVersion++; queue.revision++; stop(); clearSel(); }; const shuffle = (visibleKeys?: Set) => { const previousKeys = [...queue.keys]; const isAll = !queue.sel.length; const indices = selectedOrAllIndices(); if (!indices.length) return; 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; }); queue.revision++; requestReorderedMoves(previousKeys, visibleKeys); if (isAll) { clearSel(); } }; type SortBase = { field: SortField; label: string; setting: keyof typeof settings; dir: "az" | "hl" | "yr"; sym?: [low: string, high: string]; descFirst?: boolean; }; type SortDef = SortBase & { asc: boolean }; const sortBases: SortBase[] = [ { field: "title", label: "common.title", setting: "enableTitle", dir: "az" }, { field: "albumArtist", label: "common.album_artist", setting: "enableAlbumArtist", dir: "az", }, { field: "artist", label: "common.artist", setting: "enableArtist", dir: "az", }, { field: "album", label: "common.album", setting: "enableAlbum", dir: "az" }, { field: "genre", label: "common.genre", setting: "enableGenre", dir: "az" }, { field: "year", label: "common.year", setting: "enableYear", dir: "yr", descFirst: true, }, { field: "duration", label: "common.time", setting: "enableTime", dir: "hl", descFirst: true, }, { field: "quality", label: "common.quality", setting: "enableQuality", dir: "az", }, { field: "playCount", label: "common.play_count", setting: "enablePlayCount", dir: "hl", descFirst: true, }, { field: "disc", label: "common.disc_num", setting: "enableDisc", dir: "hl" }, { field: "track", label: "common.track_num", setting: "enableTrackNum", dir: "hl", }, { field: "stars", label: "common.favorite", setting: "enableFavorites", dir: "hl", sym: ["♡", "♥"], descFirst: true, }, { field: "rating", label: "common.rating", setting: "enableRatings", dir: "hl", sym: ["☆", "★"], descFirst: true, }, { field: "downloaded", label: "common.downloaded", setting: "enableDownloads", dir: "hl", sym: ["⤓", "✓"], descFirst: true, }, ]; const sortDefs: SortDef[] = sortBases.flatMap((base) => { const asc: SortDef = { ...base, asc: true }; const desc: SortDef = { ...base, asc: false }; return base.descFirst ? [desc, asc] : [asc, desc]; }); export const getSortItems = ( visibleKeys?: Set, showAll = false, onShowAll?: () => void, ): Item[] => { const toItem = (def: SortDef): Item => { let dir: string; if (def.sym) dir = def.asc ? `${def.sym[0]} → ${def.sym[1]}` : `${def.sym[1]} → ${def.sym[0]}`; else if (def.dir === "az") dir = def.asc ? t("sort.az") : t("sort.za"); else if (def.dir === "hl") dir = def.asc ? t("sort.low_high") : t("sort.high_low"); else dir = def.asc ? t("sort.old_new") : t("sort.new_old"); return { label: `${t(def.label)} (${dir})`, action: () => sortQueue(def.field, def.asc, visibleKeys), }; }; const availableDefs = sortDefs.filter( (def) => def.setting !== "enableDownloads" || settings.downloads, ); const defs = showAll ? availableDefs : availableDefs.filter((def) => Boolean(settings[def.setting])); const items: Item[] = [ { label: t("sort.shuffle"), action: () => shuffle(visibleKeys) }, ...defs.map(toItem), ]; if (!showAll && defs.length < availableDefs.length) return [ ...items, { label: t("common.show_more"), keepOpen: true, italic: true, action: () => onShowAll?.(), }, ]; return items; }; export const syncQueue = async () => { if (queue.tracks.length) return; try { const saved = (await api.getPlayQueue()).playQueue; if (saved) { const entries = internSongBatch(asArray(saved.entry)); // 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) => api.getAlbum(albumId).catch((err: unknown) => { console.warn( "failed to hydrate queue album metadata:", albumId, err, ); }), ), ); queue.tracks = entries; queue.keys = entries.map(() => nextTrackKey++); queue.contentVersion++; queue.revision++; 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 = { 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 api.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.isOfflineMode ) void writeQueue(next); }; const saveQueue = () => { clearTimeout(saveTimer); if (ui.busy || !auth.loggedIn || offlineManager.isOfflineMode) return; const current = queue.tracks[queue.pos]; 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([ids, currentIndex, position]); const snapshot = { 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) { if (!player.paused) play(track); else load(track, 0); } }); $effect(() => { queue.revision; queue.pos; auth.loggedIn; ui.busy; offlineManager.isOfflineMode; 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);