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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206<script lang="ts">import { untrack } from "svelte";import { SvelteMap } from "svelte/reactivity";import { Virtualizer, type VirtualizerHandle } from "virtua/svelte";import { auth, isOwner } from "../api/auth.svelte.js";import { api, asArray, canPlayTrack, radioStationToSong, songAlbumArtist,} from "../api/client.svelte.js";
import type { Album, Artist, Song } from "../api/types.js";import { effectiveAutoSyncMode, settings } from "../app/settings.svelte.js";import ContextMenu, { type Item } from "../components/ContextMenu.svelte";import Icon from "../icons/Icon.svelte";import { t } from "../languages/translate.svelte.js";import BrowseFilter from "../library/BrowseFilter.svelte";import BrowseFilterPanel from "../library/BrowseFilterPanel.svelte";import { type BrowseFilters, type BrowseMode, browseArtistName, browseGenreText, browseRating, browseYear, defaultBrowseFilters, defaultSortDir, foldSearch, sortBrowseItems,} from "../library/browse.js";import LibraryLabel from "../library/LibraryLabel.svelte";import LibraryTreeItem from "../library/LibraryTreeItem.svelte";import { type ChildFactory, isSong, isTrackType, itemKey, itemLabel, type LibraryItem, lib, recursiveSongs, type TreeType, yearDetail,} from "../library/store.svelte.js";import { offlineManager } from "../offline/offlineManager.svelte.js";import { player } from "../playback/player.svelte.js";import { insertAt, queue, queueInsertIndex } from "../playback/queue.svelte.js";import { animateActivation, animateButtonRejection, animateTreeExpansion, captureTreeItemPositions, entryStartWindow, motionEnabled, transparencyEnabled,} from "../ui/motion.svelte.js";import { scrollFade } from "../ui/scrollFade.js";import { shouldIgnoreKey, ui } from "../ui/state.svelte.js";import { hasTitlebarArea, narrowViewport } from "../ui/window.svelte.js";import { getVirtualizerPageSize } from "./virtualizer.js";
const searchTypes = ["artist", "album", "song"] as const;type SearchType = (typeof searchTypes)[number];type SectionName = | "playlists" | "shared" | "shares" | "radio" | `search:${SearchType}` | "browse";
type SectionRow = { kind: "section"; key: string; label: string; expanded: boolean; toggle: () => void; onAdd?: () => void;};
type StatusRow = { kind: "offline-status" | "download-status"; key: string;};
type ItemRow = { kind: "item"; key: string; depth: number; id: string; label: string; detail?: string; fetcher?: () => Promise<LibraryItem[]>; childFactory?: ChildFactory; track?: Song; type: TreeType; owner?: string | null; item?: LibraryItem; onActivate?: () => void; onUpdate?: () => void; onDelete?: () => void;};
type LibraryRow = SectionRow | StatusRow | ItemRow;
interface SearchResults { artist?: Artist[]; album?: Album[]; song?: Song[];}
type NodeState = { expanded: boolean; items: LibraryItem[] | null; loading: boolean; actionBusy: boolean; generation: number; node: ItemRow;};
let query = $state("");let browseFilters = $state<BrowseFilters>(defaultBrowseFilters());let filterOpen = $state(false);const filterPanelId = $props.id();const trimmedQuery = $derived(query.trim());let searchSeq = 0;let searchGen = $state(0);let results = $state<SearchResults | null>(null);let nodeStates = $state<Record<string, NodeState>>({});let menu = $state<{ x: number; y: number; items: Item[]; keyboard?: boolean;} | null>(null);let libraryElement = $state<HTMLElement>();let virtualizer = $state<VirtualizerHandle>();let headerHeight = $state(0);let programmaticScroll = false;type TreeEntryRequest = { keys?: readonly string[]; includeLabels: boolean;};
let treeEntryRequest = $state<TreeEntryRequest | null>(null);let pendingEntryAnimations = $state(new SvelteMap<string, number>());let pendingEntryAddedAt = new SvelteMap<string, number>();const scrollFadeTop = $derived(transparencyEnabled() && hasTitlebarArea());const scrollFadeBottom = $derived( transparencyEnabled() && !settings.borders && (!!player.track || narrowViewport.current),);
let expanded = $state<Record<SectionName, boolean>>({ playlists: true, shared: true, shares: true, radio: true, "search:artist": true, "search:album": true, "search:song": true, browse: true,});
const myPlaylists = $derived( lib.playlists.filter((playlist) => isOwner(playlist.owner)),);const sharedPlaylists = $derived( lib.playlists.filter((playlist) => !isOwner(playlist.owner)),);
function parseYear(value: number | null): number | undefined { return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 9999 ? value : undefined;}
const rangeBounds = (from: number | undefined, to: number | undefined) => ({ low: from !== undefined && to !== undefined ? Math.min(from, to) : from, high: from !== undefined && to !== undefined ? Math.max(from, to) : to,});
const SEARCH_COUNT = 100;
let browseCache = $state<Record<BrowseMode, BrowseItem[] | null>>({ artists: null, albums: null, songs: null,});let browseSeq = 0;let browseCacheOffline: Record<BrowseMode, boolean | null> = { artists: null, albums: null, songs: null,};
type BrowseItem = Album | Song | Artist;interface BrowseMeta { type: SearchType; load: (isCurrent: () => boolean) => Promise<BrowseItem[]>;}
const browseMeta: Record<BrowseMode, BrowseMeta> = { artists: { type: "artist", load: api.getArtists }, albums: { type: "album", load: api.fetchAllAlbums }, songs: { type: "song", load: api.fetchAllSongs },};
const browsePending = $derived( !browseCache[browseFilters.mode] || browseCacheOffline[browseFilters.mode] !== offlineManager.isOfflineMode,);
const sortedBrowse = $derived.by((): BrowseItem[] => { const { mode, sortBy, sortDir } = browseFilters; const seed = randomSeed; if (browsePending) return []; const raw: BrowseItem[] = browseCache[mode] ?? []; const dir: 1 | -1 = sortDir === "desc" ? -1 : 1; return untrack(() => sortBrowseItems(raw, sortBy, dir, seed));});
const browseItems = $derived.by((): BrowseItem[] => { const { mode } = browseFilters; const filterNeedle = foldSearch(debouncedFilterText); const genreNeedle = foldSearch(debouncedGenreText); const years = rangeBounds( parseYear(browseFilters.fromYear), parseYear(browseFilters.toYear), ); const ratings = rangeBounds( browseFilters.fromRating ?? undefined, browseFilters.toRating ?? undefined, ); const downloadedOnly = browseFilters.downloaded && settings.downloads; const downloadedIds = !downloadedOnly ? null : mode === "artists" ? offlineManager.downloadedArtistIds : mode === "albums" ? offlineManager.downloadedAlbumIds : offlineManager.downloadedSongIds; return sortedBrowse.filter((item) => { if (browseFilters.starred && item.starred === undefined) return false; if (downloadedIds && !downloadedIds.has(item.id)) return false; const rating = browseRating(item); if (ratings.low !== undefined && rating < ratings.low) return false; if (ratings.high !== undefined && rating > ratings.high) return false; if (mode !== "artists") { const year = browseYear(item); if (years.low !== undefined || years.high !== undefined) { if (year === undefined) return false; if (years.low !== undefined && year < years.low) return false; if (years.high !== undefined && year > years.high) return false; } if ( genreNeedle && !foldSearch(browseGenreText(item)).includes(genreNeedle) ) return false; } if (filterNeedle) { const haystack = foldSearch( `${itemLabel(item)} ${browseArtistName(item)}${"title" in item ? ` ${item.album}` : ""}`, ); if (!haystack.includes(filterNeedle)) return false; } return true; });});
function searchDetail(item: LibraryItem, type: SearchType): string | undefined { let artist: string | undefined; if (type === "song" && isSong(item)) { artist = songAlbumArtist(item); } else if (type === "album" && "artist" in item) { artist = item.displayArtist || item.artist; } else if (type === "artist" && "disambiguation" in item) { return item.disambiguation?.trim() || undefined; } else { return undefined; } return artist?.trim() || undefined;}
function clearNodeStates(prefix: string) { untrack(() => { for (const key of Object.keys(nodeStates)) { if (key.startsWith(prefix)) delete nodeStates[key]; } });}
$effect(() => { if (!trimmedQuery) { ++searchSeq; const hadResults = untrack(() => results !== null); results = null; clearNodeStates("search:"); if (hadResults) { requestTreeEntries(); scrollToTop(); } return; } const runId = ++searchSeq; const timer = setTimeout(async () => { try { const next = await api.search3(trimmedQuery, { artistCount: SEARCH_COUNT, albumCount: SEARCH_COUNT, songCount: SEARCH_COUNT, }); if (runId === searchSeq) setSearchResults(next); } catch (err) { console.error("search failed:", err); } }, 300); return () => clearTimeout(timer);});
let prevBrowseSignature: string | undefined;let prevBrowseMode: BrowseMode | undefined;let prevSortBy: BrowseFilters["sortBy"] | undefined;let randomSeed = $state((Math.random() * 2 ** 31) | 0);let debouncedFilterText = $state("");let debouncedGenreText = $state("");$effect(() => { const filterText = browseFilters.filter; const genreText = browseFilters.genre; if (!filterText.trim() && !genreText.trim()) { debouncedFilterText = ""; debouncedGenreText = ""; return; } const timer = setTimeout(() => { debouncedFilterText = filterText; debouncedGenreText = genreText; }, 300); return () => clearTimeout(timer);});$effect(() => { const mode = browseFilters.mode; const sortBy = browseFilters.sortBy; const signature = `${mode}|${sortBy}|${browseFilters.sortDir}|${browseFilters.starred}|${browseFilters.downloaded}|${browseFilters.fromYear}|${browseFilters.toYear}|${browseFilters.fromRating}|${browseFilters.toRating}`; if (prevBrowseSignature === undefined) { prevBrowseSignature = signature; prevBrowseMode = mode; prevSortBy = sortBy; return; } if (signature === prevBrowseSignature) return; prevBrowseSignature = signature; if (mode !== prevBrowseMode) { prevBrowseMode = mode; scrollToTop(); } if (sortBy !== prevSortBy) { prevSortBy = sortBy; if (sortBy !== "random") browseFilters.sortDir = defaultSortDir(sortBy); } if (sortBy === "random") randomSeed = (Math.random() * 2 ** 31) | 0; requestTreeEntries(["browse"]);});
$effect(() => { const mode = browseFilters.mode; const offline = offlineManager.isOfflineMode; const cached = browseCache[mode]; if (cached && browseCacheOffline[mode] === offline) return; const runId = ++browseSeq; const loadBrowse = async () => { try { clearNodeStates(`browse/${browseMeta[mode].type}:`); const items = await browseMeta[mode].load(() => runId === browseSeq); if (runId !== browseSeq) return; browseCache[mode] = items; browseCacheOffline[mode] = offline; requestTreeEntries(["browse"]); } catch (err) { console.error("browse failed:", err); } }; void loadBrowse();});
function getNodeState(node: ItemRow) { if (!nodeStates[node.key]) { nodeStates[node.key] = { expanded: false, items: null, loading: false, actionBusy: false, generation: 0, node, }; } const state = nodeStates[node.key]; state.node = node; return state;}
async function loadNode( node: ItemRow, state: NodeState, beforeCommit?: () => void,) { if (!node.fetcher) return; const generation = ++state.generation; state.loading = true; try { const items = await node.fetcher(); if (state.generation === generation) { beforeCommit?.(); state.items = items; } } catch (err) { console.error(err); } finally { if (state.generation === generation) state.loading = false; }}
function isNodeBusy(node: ItemRow) { const state = nodeStates[node.key]; return !!(state?.loading || state?.actionBusy);}
function isPrimaryUnavailable(node: ItemRow) { return !node.fetcher && !node.onActivate;}
function requestTreeEntries(keys?: readonly string[], includeLabels = false) { if (!motionEnabled()) return; const current = untrack(() => treeEntryRequest); treeEntryRequest = { keys: current ? current.keys === undefined || keys === undefined ? undefined : [...new Set([...current.keys, ...keys])] : keys, includeLabels: current?.includeLabels || includeLabels, };}
function finishEntryAnimation(key: string) { if (!pendingEntryAnimations.has(key)) return; pendingEntryAnimations.delete(key); pendingEntryAddedAt.delete(key);}
function pendingEntryIndex(key: string): number | undefined { const index = pendingEntryAnimations.get(key); if (index === undefined) return undefined; const addedAt = pendingEntryAddedAt.get(key); if (addedAt === undefined || Date.now() - addedAt > entryStartWindow) return undefined; return index;}
function handleScroll() { if (programmaticScroll) return; pendingEntryAddedAt.clear(); if (pendingEntryAnimations.size) pendingEntryAnimations.clear();}
function handleScrollEnd() { programmaticScroll = false;}
function scrollToTop() { if (!virtualizer || virtualizer.getScrollOffset() === 0) return; programmaticScroll = true; virtualizer.scrollTo(0);}
$effect(() => { if (!motionEnabled()) { if (treeEntryRequest) treeEntryRequest = null; pendingEntryAddedAt.clear(); if (pendingEntryAnimations.size) pendingEntryAnimations.clear(); return; } if (!treeEntryRequest) return; const request = treeEntryRequest; treeEntryRequest = null; admitTreeEntries(request);});
function admitTreeEntries(request: TreeEntryRequest) { const rows = visibleRows; const now = Date.now(); const hasPending = pendingEntryAnimations.size > 0 || pendingEntryAddedAt.size > 0; const alive = hasPending ? new Set(rows.map((row) => row.key)) : undefined; const pending = new SvelteMap<string, number>(); if (alive) { for (const [key, index] of pendingEntryAnimations) { if (alive.has(key)) pending.set(key, index); } } const keys = request.keys; const includeLabels = request.includeLabels; let matchIndex = 0; for (const row of rows) { if (row.kind !== "section" && row.kind !== "item") continue; if ( keys && !keys.some( (key) => row.key.startsWith(`${key}/`) || (includeLabels && row.key === key), ) ) continue; const index = matchIndex++; if (pending.has(row.key)) continue; pending.set(row.key, index); pendingEntryAddedAt.set(row.key, now); } if (alive) { for (const [key, addedAt] of pendingEntryAddedAt) { if (!alive.has(key) || now - addedAt > entryStartWindow) pendingEntryAddedAt.delete(key); } } pendingEntryAnimations = pending;}
function setSearchResults(next: SearchResults) { results = next; clearNodeStates("search:"); searchGen += 1; scrollToTop(); requestTreeEntries( searchTypes.map((type) => `search:${type}`), true, );}
$effect(() => { if (lib.playlists.length) { requestTreeEntries(["lib-playlists", "lib-shared-playlists"]); } if (lib.radioStations.length) requestTreeEntries(["lib-radio"]); if (lib.shares.length) requestTreeEntries(["lib-shares"]);});
async function activateNode(node: ItemRow) { if (!node.fetcher || isTrackType(node.type)) { node.onActivate?.(); return; } const state = getNodeState(node); if (isNodeBusy(node)) return;
if (state.expanded) { state.expanded = false; if (lib.focusedId?.startsWith(`${node.key}/`)) lib.focusedId = node.key; return; }
state.expanded = true; let positions: ReturnType<typeof captureTreeItemPositions>; if (state.items) { positions = captureTreeItemPositions(libraryElement); } else { await loadNode(node, state, () => { positions = captureTreeItemPositions(libraryElement); }); } animateTreeExpansion(libraryElement, node.key, positions);}
async function addNode(node: ItemRow, asNext: boolean) { const index = queueInsertIndex(asNext); if (isTrackType(node.type) && node.track) { if (canPlayTrack(node.track)) insertAt(index, node.track); return; } if (!node.fetcher) return; const state = getNodeState(node); if (isNodeBusy(node)) return; state.actionBusy = true; try { insertAt( index, await recursiveSongs(await node.fetcher(), node.childFactory), ); } catch (err) { console.error(err); } finally { state.actionBusy = false; }}
function nextOccurrence(counts: Map<string, number>, id: string) { const occurrence = counts.get(id) ?? 0; counts.set(id, occurrence + 1); return occurrence;}
function appendItem(rows: LibraryRow[], node: ItemRow) { rows.push(node); const state = nodeStates[node.key]; if (!state?.expanded || !state.items) return;
const childType: TreeType = node.type === "artist" ? "album" : "song"; const occurrences = new Map<string, number>(); for (const item of state.items) { const occurrence = nextOccurrence(occurrences, item.id); appendItem(rows, { kind: "item", key: itemKey(node.key, childType, item.id, occurrence), depth: node.depth + 1, id: item.id, label: itemLabel(item), detail: childType === "album" ? yearDetail(item) : undefined, fetcher: node.childFactory?.(item), track: childType === "song" && isSong(item) ? item : undefined, type: childType, item, }); }}
function appendItemSection( rows: LibraryRow[], items: LibraryItem[], sectionKey: SectionName, rowType: SearchType,) { const occurrences = new Map<string, number>(); const itemRows: ItemRow[] = items.map((item) => { const occurrence = nextOccurrence(occurrences, item.id); return { kind: "item", key: itemKey(sectionKey, rowType, item.id, occurrence), depth: 0, id: item.id, label: itemLabel(item), detail: searchDetail(item, rowType), fetcher: rowType === "artist" ? () => api.getArtistAlbums(item.id) : rowType === "album" ? () => api.getAlbum(item.id) : undefined, childFactory: rowType === "artist" ? (child: LibraryItem) => () => api.getAlbum(child.id) : undefined, track: rowType === "song" && isSong(item) ? item : undefined, type: rowType, item, }; }); appendSection( rows, sectionKey, t(`common.${rowType}s`), itemRows, sectionKey, rowType === "song" ? () => { if (lib.loading) return; const songs = itemRows.flatMap(({ track }) => track && canPlayTrack(track) ? [track] : [], ); if (songs.length) insertAt(queue.tracks.length, songs); } : undefined, );}
function appendSection( rows: LibraryRow[], key: string, label: string, items: ItemRow[], section: SectionName, onAdd?: () => void,) { const isExpanded = expanded[section]; rows.push({ kind: "section", key, label, expanded: isExpanded, toggle: () => { if (lib.loading) return; const positions = !expanded[section] ? captureTreeItemPositions(libraryElement) : undefined; expanded[section] = !expanded[section]; if (expanded[section]) { animateTreeExpansion(libraryElement, key, positions); } else if (lib.focusedId?.startsWith(`${key}/`)) lib.focusedId = key; }, onAdd, }); if (isExpanded) for (const item of items) appendItem(rows, item);}
function playlistRow( item: (typeof lib.playlists)[number], sectionKey: string,): ItemRow { return { kind: "item", key: itemKey(sectionKey, "playlist", item.id), depth: 0, id: item.id, label: item.name, fetcher: () => api.getPlaylist(item.id), type: "playlist", owner: item.owner, item, };}
function buildRows(): LibraryRow[] { const rows: LibraryRow[] = []; if (!offlineManager.online) { rows.push({ kind: "offline-status", key: "status:offline" }); } if (offlineManager.downloadProgress.downloading) { rows.push({ kind: "download-status", key: "status:download" }); }
if (results && trimmedQuery) { for (const type of searchTypes) { const items: LibraryItem[] = asArray<LibraryItem>(results[type]); if (!items.length) continue; appendItemSection(rows, items, `search:${type}`, type); } return rows; }
const { type: rowType } = browseMeta[browseFilters.mode]; appendItemSection(rows, browseItems, "browse", rowType); const playlistSection = "lib-playlists"; appendSection( rows, playlistSection, t("common.playlists"), [ ...myPlaylists.map((item) => playlistRow(item, playlistSection)), { kind: "item", key: `${playlistSection}/new-playlist`, depth: 0, id: "new-playlist", label: t("action.new_playlist"), type: "new-playlist", onActivate: () => { ui.playlistForm = { mode: "new" }; }, }, ], "playlists", );
if (sharedPlaylists.length) { const sharedSection = "lib-shared-playlists"; appendSection( rows, sharedSection, t("common.shared_playlists"), sharedPlaylists.map((item) => playlistRow(item, sharedSection)), "shared", ); }
if ( !offlineManager.isOfflineMode && lib.isRadioSupported && (auth.admin || lib.radioStations.length > 0) ) { const radioSection = "lib-radio"; const radioItems: ItemRow[] = lib.radioStations.map((station) => ({ kind: "item", key: itemKey(radioSection, "radio", station.id), depth: 0, id: `radio-${station.id}`, label: station.name, type: "radio", track: radioStationToSong(station), item: station, onUpdate: auth.admin ? () => { ui.confirmDeleteStation = null; ui.stationForm = { mode: "edit", id: station.id, name: station.name, streamUrl: station.streamUrl, homepageUrl: station.homePageUrl ?? "", }; } : undefined, onDelete: auth.admin ? () => { ui.stationForm = null; ui.confirmDeleteStation = { id: station.id, name: station.name, }; } : undefined, })); if (auth.admin) { radioItems.push({ kind: "item", key: `${radioSection}/new-station`, depth: 0, id: "new-station", label: t("action.new_station"), type: "new-station", onActivate: () => { ui.confirmDeleteStation = null; ui.stationForm = { mode: "new", id: null, name: "", streamUrl: "", homepageUrl: "", }; }, }); } appendSection(rows, radioSection, t("common.radio"), radioItems, "radio"); }
if ( !offlineManager.isOfflineMode && lib.isSharingSupported && lib.shares.length ) { const sharesSection = "lib-shares"; appendSection( rows, sharesSection, t("common.shares"), lib.shares.map((item) => ({ kind: "item", key: itemKey(sharesSection, "share", item.id), depth: 0, id: item.id, label: item.description || item.url, type: "share", owner: item.username, item, })), "shares", ); } return rows;}
const visibleRows = $derived(buildRows());const navigableRows = $derived( visibleRows.filter( (row): row is SectionRow | ItemRow => row.kind === "section" || row.kind === "item", ),);const rowIndexByKey = $derived( new Map(visibleRows.map((row, index) => [row.key, index] as const)),);const navigableIndexByKey = $derived( new Map(navigableRows.map((row, index) => [row.key, index] as const)),);
function invalidateNode(state: NodeState) { state.generation++; state.items = null; state.loading = false; if (state.expanded && state.node.fetcher) void loadNode(state.node, state);}
$effect(() => { offlineManager.isOfflineMode; untrack(() => { for (const state of Object.values(nodeStates)) { if (state.node.key.startsWith("browse")) continue; invalidateNode(state); } });});
$effect(() => { const signal = ui.lastUpdatedPlaylistId; if (!signal) return; untrack(() => { for (const state of Object.values(nodeStates)) { if ( state.node.type !== "playlist" || !signal.startsWith(`${state.node.id}:`) ) continue; invalidateNode(state); } });});
function scrollToRow(key: string) { const index = rowIndexByKey.get(key); if (index !== undefined) virtualizer?.scrollToIndex(index, { align: "nearest" });}
function showKeyboardMenu(row: ItemRow) { scrollToRow(row.key); requestAnimationFrame(() => { const element = libraryElement?.querySelector<HTMLElement>( `[data-id="${CSS.escape(row.key)}"]`, ); if (!element) return; const bounds = element.getBoundingClientRect(); element.dispatchEvent( new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: bounds.left + 50, clientY: bounds.bottom, }), ); });}
function getPageSize(focusedVirtIndex: number) { return getVirtualizerPageSize(virtualizer, focusedVirtIndex, headerHeight);}
function onKey(event: KeyboardEvent) { if (shouldIgnoreKey(event)) return; const focusedIndex = lib.focusedId ? (navigableIndexByKey.get(lib.focusedId) ?? -1) : -1; const { key, code, altKey: isAltPressed, shiftKey: isShiftPressed } = event;
if (key === "Escape") { event.preventDefault(); lib.focusedId = null; return; }
const focusedVirtIndex = rowIndexByKey.get(lib.focusedId ?? "") ?? 0; const pageSize = getPageSize(focusedVirtIndex);
const offsets: Record<string, number> = { ArrowDown: 1, ArrowUp: -1, PageDown: pageSize, PageUp: -pageSize, };
const offset = offsets[key]; if (key === "Home" || key === "End" || offset !== undefined) { event.preventDefault(); if (!navigableRows.length) return; let targetIndex: number; if (key === "Home") targetIndex = 0; else if (key === "End") targetIndex = navigableRows.length - 1; else if (focusedIndex === -1) targetIndex = 0; else targetIndex = Math.max( 0, Math.min(navigableRows.length - 1, focusedIndex + offset), ); const next = navigableRows[targetIndex]; if (next) { lib.focusedId = next.key; scrollToRow(next.key); } } else if (key === "Enter") { event.preventDefault(); const row = navigableRows[focusedIndex]; if (!row || (row.kind === "item" && isNodeBusy(row))) return; if (row.kind === "section" && lib.loading) return; const button = libraryElement?.querySelector<HTMLButtonElement>( `[data-id="${CSS.escape(row.key)}"]`, ) ?? null; if (row.kind === "item" && isPrimaryUnavailable(row)) { animateActivation(button); animateButtonRejection(button); } else animateActivation(button); if (row.kind === "section") row.toggle(); else void activateNode(row); } else if (code === "KeyA" && !event.ctrlKey && !event.metaKey) { event.preventDefault(); const row = navigableRows[focusedIndex]; if (row?.kind === "item" && !isNodeBusy(row)) void addNode(row, isAltPressed); } else if ( key === "ContextMenu" || key === "`" || (key === "F10" && isShiftPressed) ) { event.preventDefault(); const row = navigableRows[focusedIndex]; if (row?.kind === "item" && !isNodeBusy(row)) showKeyboardMenu(row); }}</script>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --><!-- svelte-ignore a11y_no_noninteractive_tabindex --><!-- biome-ignore-start lint/a11y/noNoninteractiveTabindex: Library keyboard navigation --><section bind:this={libraryElement} id="library" class:scroll-fade-top={scrollFadeTop} class:scroll-fade-bottom={scrollFadeBottom} aria-label={t("common.library")} use:scrollFade={scrollFadeTop || scrollFadeBottom} onfocusin={() => (ui.activeContext = "library")} onkeydown={onKey} onclick={(e) => { if (e.target === e.currentTarget) lib.focusedId = null; }} tabindex="0"> <div class="library-header" bind:clientHeight={headerHeight}> <div class="library-search"> <input type="search" bind:value={query} aria-label={t("common.search")} placeholder={t("common.search")} > </div> {#if offlineManager.queuedRequests.length > 0 && !ui.miniplayer} <button type="button" class="native unsynced-changes" onclick={() => (ui.showUnsyncedChanges = true)} > {t("offline.unsynced_changes")} ({offlineManager.queuedRequests.length}) </button> {/if} </div> <Virtualizer bind:this={virtualizer} data={visibleRows} getKey={(row) => row.key} bufferSize={600} startMargin={headerHeight} onscroll={handleScroll} onscrollend={handleScrollEnd} as="ul" item="li" > {#snippet children( row, )} {#if row.kind === "section"} <LibraryLabel rowKey={row.key} label={row.label} expanded={row.expanded} focused={lib.focusedId === row.key} disabled={lib.loading || (row.key === "browse" && browsePending)} onToggle={row.toggle} onAdd={row.onAdd} entryIndex={pendingEntryIndex(row.key)} entryGen={searchGen} onEntryAnimationEnd={finishEntryAnimation} > {#snippet actions()} {#if row.key === "browse"} <BrowseFilter filters={browseFilters} bind:open={filterOpen} panelId={filterPanelId} /> {/if} {/snippet} </LibraryLabel> {:else if row.kind === "offline-status"} <div role="status"> {#if auth.authFailed} {t("offline.auth_failed_notice")} {:else} {t( effectiveAutoSyncMode() === "none" ? "offline.offline_notice" : "offline.offline_notice_auto_sync", )} {/if} </div> {:else if row.kind === "download-status"} <div role="status"> <span> {t("offline.download_progress", { current: offlineManager.downloadProgress.current, total: offlineManager.downloadProgress.total, })} </span> <button type="button" onclick={() => offlineManager.cancelDownloads()} aria-label={t("offline.cancel_downloads")} title={t("offline.cancel_downloads")} > <Icon name="close" /> </button> </div> {:else if row.kind === "item"} {@const state = nodeStates[row.key]} <LibraryTreeItem id={row.id} rowKey={row.key} label={row.label} detail={row.detail} fetcher={row.fetcher} childFactory={row.childFactory} track={row.track} type={row.type} owner={row.owner} item={row.item} depth={row.depth} expanded={state?.expanded ?? false} busy={isNodeBusy(row)} primaryUnavailable={isPrimaryUnavailable(row)} onPrimaryAction={() => activateNode(row)} onAdd={(asNext) => addNode(row, asNext)} onMenu={(nextMenu) => (menu = nextMenu)} onUpdate={row.onUpdate} onDelete={row.onDelete} entryIndex={pendingEntryIndex(row.key)} entryGen={searchGen} onEntryAnimationEnd={finishEntryAnimation} /> {/if} {/snippet} </Virtualizer></section><!-- biome-ignore-end lint/a11y/noNoninteractiveTabindex: Library keyboard navigation -->
{#if menu} <ContextMenu x={menu.x} y={menu.y} items={menu.items} keyboard={menu.keyboard} onclose={() => (menu = null)} />{/if}
{#if filterOpen} <BrowseFilterPanel bind:filters={browseFilters} bind:open={filterOpen} scrollport={libraryElement} panelId={filterPanelId} />{/if}
<style>#library { flex: 1; overflow-y: auto; padding-block: 0 0.5rem; padding-inline: 1rem;}.library-header { padding-block-start: 0.5rem;}.library-search { > input { inline-size: 100%; }}#library > :global(ul) { padding: 0; margin: 0; list-style: none;}[role="status"] { font-size: smaller; padding-block-start: 0.5rem; display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; text-wrap: pretty;}[role="status"] > button { display: flex; align-items: center;}.unsynced-changes { margin-block-start: 0.5rem; inline-size: 100%;}</style>