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.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184import { 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<number>,) => { 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<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 || (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<number>) => { 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<number>,) => { 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<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, 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<number>,): 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<number>(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<number>,): 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<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; 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<number>) => { 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<number>,): 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<number>) => { 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<number>) => { 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<number>) => { 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<number>, 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<typeof setTimeout> | 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);