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.
Svelte
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088<script lang="ts">import { autoUpdate, computePosition, flip, offset, shift,} from "@floating-ui/dom";import { untrack } from "svelte";import { fade } from "svelte/transition";import { Virtualizer, type VirtualizerHandle } from "virtua/svelte";import { activateSource, auth, isOwner } from "./auth.svelte.js";import { type AlbumListParams, type AlbumListType, asArray, canPlayTrack, getSourceApi, radioStationToSong, type Song,} from "./client.svelte.js";import ContextMenu, { type Item } from "./components/ContextMenu.svelte";import Icon from "./icons/Icon.svelte";import LibraryLabel from "./LibraryLabel.svelte";import LibraryTreeItem from "./LibraryTreeItem.svelte";import { t } from "./languages/translate.svelte.js";import { type ChildFactory, clearLibrary, isSong, itemKey, itemLabel, type LibraryItem, lib, recursiveSongs, type TreeType,} from "./library.svelte.js";import { animateActivation, animateButtonRejection, animateTreeEntries, animateTreeExpansion, captureTreeItemPositions, motionEnabled,} from "./motion.js";import { offlineManager } from "./offline/offlineManager.svelte.js";import { insertAt, queue } from "./queue.svelte.js";import { scrollFade } from "./scrollFade.js";import { effectiveAutoSyncMode, settings } from "./settings.svelte.js";import { orderedSources, sourceDisplayName, sources,} from "./sources.svelte.js";import { transparencyEnabled } from "./transparency.js";import { ui } from "./ui.svelte.js";
const searchTypes = ["artist", "album", "song"] as const;type SearchType = (typeof searchTypes)[number];const librarySources = $derived(orderedSources());type SectionName = | "artists" | "playlists" | "shared" | "shares" | "radio" | `search:${SearchType}`;
type SectionRow = { kind: "section"; key: string; label: string; expanded: boolean; toggle: () => void; onAdd?: () => void;};
type ItemRow = { kind: "item"; key: string; depth: number; id: string; label: string; fetcher?: () => Promise<LibraryItem[]>; childFactory?: ChildFactory; track?: Song; type: TreeType; owner?: string | null; item?: LibraryItem; onActivate?: () => void; onUpdate?: () => void; onDelete?: () => void;};
type LibraryRow = SectionRow | ItemRow;
type NodeState = { expanded: boolean; items: LibraryItem[] | null; loading: boolean; actionBusy: boolean; generation: number; node: ItemRow;};
let query = $state("");let searchHovered = $state(false);let searchHelpVisible = $state(false);let searchInput = $state<HTMLInputElement>();let searchHelp = $state<HTMLElement>();let results = $state<Record<string, unknown> | 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);const scrollFadeEnabled = $derived( settings.transparentOutlines && transparencyEnabled(),);
const albumTypeAliases: Record<string, AlbumListType> = { random: "random", newest: "newest", highest: "highest", frequent: "frequent", recent: "recent", alphabetical: "alphabeticalByName", alphabeticalbyartist: "alphabeticalByArtist", starred: "starred",};
let expanded = $state<Record<SectionName, boolean>>({ artists: true, playlists: true, shared: true, shares: true, radio: true, "search:artist": true, "search:album": true, "search:song": true,});
const myPlaylists = $derived( lib.playlists.filter((playlist) => isOwner(playlist.owner)),);const sharedPlaylists = $derived( lib.playlists.filter((playlist) => !isOwner(playlist.owner)),);
function numberParam( params: URLSearchParams, name: string, min: number, max: number,) { const value = params.get(name); if (!value || !/^\d+$/.test(value)) return undefined; const parsed = Number(value); return parsed >= min && parsed <= max ? parsed : undefined;}
function parseYearRange(value: string) { const match = value.match(/^(\d{4})(?:-(\d{4}))?$/); if (!match) return null; const fromYear = parseInt(match[1], 10); const toYear = match[2] ? parseInt(match[2], 10) : fromYear; return fromYear <= toYear ? { fromYear, toYear } : null;}
function parseAlbumsPrefix( raw: string,): (AlbumListParams & { type: AlbumListType }) | null { const [command, options] = raw.split("?", 2); const match = command.match(/^albums:([^:]+)(?::(.+))?$/i); if (!match) return null; const typePart = match[1].toLowerCase(); const arg = match[2]?.trim(); const searchParams = new URLSearchParams(options); const params = { size: numberParam(searchParams, "size", 1, 500), offset: numberParam(searchParams, "offset", 0, Number.MAX_SAFE_INTEGER), }; const normalizedType = albumTypeAliases[typePart]; if (normalizedType) return { type: normalizedType, ...params }; if (typePart === "year" && arg) { const years = parseYearRange(arg); if (years) return { type: "byYear", ...years, ...params }; } if (typePart === "genre" && arg) { return { type: "byGenre", genre: arg, ...params }; } return null;}
function parseSongsPrefix(raw: string): AlbumListParams | null { const [command, options] = raw.split("?", 2); const match = command.match(/^songs:([^:]+)(?::(.+))?$/i); if (!match) return null; const type = match[1].toLowerCase(); const arg = match[2]?.trim(); const params = new URLSearchParams(options); const size = numberParam(params, "size", 1, 500); if (type === "random" && !arg) return { size }; if (type === "genre" && arg) return { genre: arg, size }; if (type !== "year" || !arg) return null; const years = parseYearRange(arg); return years ? { ...years, size } : null;}
$effect(() => { searchHelpVisible = false; if (!searchHovered || query.trim()) return; const timer = window.setTimeout(() => { if (searchHovered && !query.trim()) searchHelpVisible = true; }, 2000); return () => clearTimeout(timer);});
$effect(() => { const reference = searchInput; const floating = searchHelp; if (!searchHelpVisible || !reference || !floating) return;
const updatePosition = () => { void computePosition(reference, floating, { strategy: "fixed", placement: "bottom-start", middleware: [offset(8), flip(), shift({ padding: 16 })], }).then(({ x, y }) => { floating.style.left = `${x}px`; floating.style.top = `${y}px`; }); };
updatePosition(); return autoUpdate(reference, floating, updatePosition);});
$effect(() => { untrack(() => { for (const key of Object.keys(nodeStates)) { if (key.startsWith("search:")) delete nodeStates[key]; } }); const sourceId = sources.activeId; if (!sourceId || !query.trim()) { const hadResults = results !== null; results = null; if (hadResults) animateTreeEntries(libraryElement); return; } const requestQuery = query; const albumsCmd = parseAlbumsPrefix(requestQuery); const songsCmd = parseSongsPrefix(requestQuery); const timer = setTimeout(async () => { try { const sourceApi = getSourceApi(sourceId); if (albumsCmd) { const { type, ...params } = albumsCmd; const albums = await sourceApi.getAlbumList2(type, params); if (query === requestQuery && sources.activeId === sourceId) setSearchResults({ album: albums }); } else if (songsCmd) { const songs = await sourceApi.getRandomSongs(songsCmd); if (query === requestQuery && sources.activeId === sourceId) setSearchResults({ song: songs }); } else { const res = await sourceApi.search3(requestQuery); if (query === requestQuery && sources.activeId === sourceId) setSearchResults(res || {}); } } catch (err) { console.error("search failed:", err); } }, 300); return () => clearTimeout(timer);});
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) { if (!node.fetcher) return; const generation = ++state.generation; state.loading = true; try { const items = await node.fetcher(); if (state.generation === generation) state.items = items; } catch (err) { console.error(err); } finally { if (state.generation === generation) state.loading = false; }}
function isTrackType(type: TreeType) { return type === "song" || type === "radio";}
function isNodeBusy(node: ItemRow) { const state = nodeStates[node.key]; return !!(state?.loading || state?.actionBusy);}
function isPrimaryUnavailable(node: ItemRow) { return !node.fetcher && !node.onActivate;}
function setSearchResults(next: Record<string, unknown>) { results = next; animateTreeEntries( libraryElement, searchTypes.map((type) => `search:${type}`), true, );}
$effect(() => { if (lib.artists.length) animateTreeEntries(libraryElement, ["lib-artists"]);});
$effect(() => { if (lib.playlists.length) { animateTreeEntries(libraryElement, [ "lib-playlists", "lib-shared-playlists", ]); }});
$effect(() => { if (lib.radioStations.length) animateTreeEntries(libraryElement, ["lib-radio"]);});
$effect(() => { if (lib.shares.length) animateTreeEntries(libraryElement, ["lib-shares"]);});
async function activateNode(node: ItemRow) { if (!node.fetcher || isTrackType(node.type)) { node.onActivate?.(); return; } const state = getNodeState(node); if (isNodeBusy(node)) return; const positions = !state.expanded ? captureTreeItemPositions(libraryElement) : undefined; state.expanded = !state.expanded; if (!state.expanded) { if (lib.focusedId?.startsWith(`${node.key}/`)) lib.focusedId = node.key; return; } if (!state.items) await loadNode(node, state); if (state.expanded && state.items) { animateTreeExpansion(libraryElement, node.key, positions); }}
async function addNode(node: ItemRow, asNext: boolean) { const index = asNext ? queue.pos + 1 : queue.tracks.length; 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 appendItem(rows: LibraryRow[], node: ItemRow) { rows.push(node); const state = nodeStates[node.key]; if (!state?.expanded || state.loading || !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 = occurrences.get(item.id) ?? 0; occurrences.set(item.id, occurrence + 1); appendItem(rows, { kind: "item", key: itemKey(node.key, childType, item.id, occurrence), depth: node.depth + 1, id: item.id, label: itemLabel(item), fetcher: node.childFactory?.(item), track: node.type !== "artist" && isSong(item) ? item : undefined, type: childType, item, }); }}
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: () => { 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 { const sourceId = item.sourceId || sources.activeId || ""; return { kind: "item", key: itemKey(sectionKey, "playlist", item.id), depth: 0, id: item.id, label: item.name, fetcher: sourceId ? () => getSourceApi(sourceId).getPlaylist(item.id) : undefined, type: "playlist", owner: item.owner, item, };}
function buildRows(): LibraryRow[] { const rows: LibraryRow[] = []; if (!sources.activeId || lib.sourceId !== sources.activeId) return rows; const sourceId = sources.activeId; if (query && results) { for (const type of searchTypes) { const items = asArray(results[type]) as LibraryItem[]; if (!items.length) continue; const sectionKey = `search:${type}` as const; const occurrences = new Map<string, number>(); const itemRows = items.map((item) => { const occurrence = occurrences.get(item.id) ?? 0; occurrences.set(item.id, occurrence + 1); return { kind: "item" as const, key: itemKey(sectionKey, type, item.id, occurrence), depth: 0, id: item.id, label: itemLabel(item), fetcher: type === "artist" ? () => getSourceApi(sourceId).getArtistAlbums(item.id) : type === "album" ? () => getSourceApi(sourceId).getAlbum(item.id) : undefined, childFactory: type === "artist" ? (child: LibraryItem) => () => getSourceApi(child.sourceId || sourceId).getAlbum(child.id) : undefined, track: type === "song" && isSong(item) ? item : undefined, type, item, }; }); appendSection( rows, sectionKey, t(`common.${type}s` as const), itemRows, sectionKey, type === "song" ? () => { const songs = itemRows.flatMap(({ track }) => track && canPlayTrack(track) ? [track] : [], ); if (songs.length) insertAt(queue.tracks.length, songs); } : undefined, ); } return rows; }
const artistSection = "lib-artists"; appendSection( rows, artistSection, t("common.artists"), lib.artists.map((item) => ({ kind: "item", key: itemKey(artistSection, "artist", item.id), depth: 0, id: item.id, label: item.name, fetcher: () => getSourceApi(sourceId).getArtistAlbums(item.id), childFactory: (child) => () => getSourceApi(child.sourceId || sourceId).getAlbum(child.id), type: "artist", item, })), "artists", );
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.isSourceOffline(sourceId) && 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, sourceId: station.sourceId || sourceId, name: station.name, streamUrl: station.streamUrl, homepageUrl: station.homePageUrl ?? "", }; } : undefined, onDelete: auth.admin ? () => { ui.stationForm = null; ui.confirmDeleteStation = { id: station.id, name: station.name, sourceId: station.sourceId || sourceId, }; } : 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, sourceId, name: "", streamUrl: "", homepageUrl: "", }; }, }); } appendSection(rows, radioSection, t("common.radio"), radioItems, "radio"); }
if ( !offlineManager.isSourceOffline(sourceId) && 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", ),);
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)) 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, align: "nearest" | "center" = "nearest") { const index = visibleRows.findIndex((row) => row.key === key); if (index >= 0) virtualizer?.scrollToIndex(index, { align });}
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 onKey(event: KeyboardEvent) { if ( (event.target as HTMLElement).closest( "input, button, select, textarea, [contenteditable]", ) ) return; const focusedIndex = navigableRows.findIndex( (row) => row.key === lib.focusedId, ); const { key, code, altKey: isAltPressed, shiftKey: isShiftPressed } = event;
if (key === "Escape") { event.preventDefault(); lib.focusedId = null; return; }
const offsets: Record<string, number> = { ArrowDown: 1, ArrowUp: -1, PageDown: 10, PageUp: -10, Home: -Infinity, End: Infinity, };
if (key in offsets) { event.preventDefault(); const targetIndex = Math.max( 0, Math.min(navigableRows.length - 1, focusedIndex + offsets[key]), ); const next = navigableRows[targetIndex]; if (next) { lib.focusedId = next.key; scrollToRow( next.key, focusedIndex >= 0 && (key === "ArrowDown" || key === "ArrowUp") ? "center" : "nearest", ); } } else if (key === "Enter") { event.preventDefault(); const row = navigableRows[focusedIndex]; if (!row || (row.kind === "item" && isNodeBusy(row))) 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); }}
async function switchSource(sourceId: string) { if (sourceId === sources.activeId) return; query = ""; results = null; nodeStates = {}; clearLibrary(); await activateSource(sourceId);}</script>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --><!-- svelte-ignore a11y_no_noninteractive_tabindex --><!-- biome-ignore-start lint/a11y/noNoninteractiveTabindex: Library keyboard navigation --><nav bind:this={libraryElement} id="library" class:scroll-fade={scrollFadeEnabled} aria-label={t("common.library")} use:scrollFade={scrollFadeEnabled} 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}> {#if librarySources.length > 1} <div class="source-tabs"> {#each librarySources as source (source.id)} <button type="button" aria-pressed={source.id === sources.activeId} class="tab pressable pressable--gentle" class:active={source.id === sources.activeId} onclick={() => void switchSource(source.id)} > {sourceDisplayName(source, librarySources)} </button> {/each} </div> {/if} <input bind:this={searchInput} id="search" type="search" bind:value={query} aria-label={t("common.search")} aria-describedby={searchHelpVisible ? "search-help" : undefined} placeholder={t("common.search")} disabled={!sources.activeId || lib.sourceId !== sources.activeId} onfocusin={(e) => e.stopPropagation()} onmouseenter={() => (searchHovered = true)} onmouseleave={() => (searchHovered = false)} onclick={(e) => e.stopPropagation()} > {#if searchHelpVisible} <div bind:this={searchHelp} id="search-help" class="search-help" role="tooltip" out:fade={{ duration: motionEnabled() ? 400 : 0 }} {@attach (element) => { document.body.append(element); return () => element.remove(); }} > albums:newest, albums:random, albums:highest, albums:frequent, albums:recent, albums:starred, albums:alphabetical, albums:alphabeticalByArtist, albums:year:2002, albums:year:2000-2010, albums:genre:Jazz<br> <br> songs:random, songs:genre:Jazz, songs:year:2002, songs:year:2000-2010<br> <br> ?size=500 </div> {/if} {#if sources.activeId && (offlineManager.isSourceOffline(sources.activeId) || offlineManager.downloadProgress.downloading || (offlineManager.queuedRequests.length > 0 && !ui.miniplayer))} <div class="library-statuses"> {#if offlineManager.downloadProgress.downloading} <div class="text-footnote" 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> {/if} {#if offlineManager.isSourceOffline(sources.activeId)} <div class="text-footnote" role="status"> {#if auth.authFailed} {t("offline.auth_failed_notice")} {:else if !offlineManager.isNetworkAvailable} {t( effectiveAutoSyncMode() === "none" ? "offline.offline_notice" : "offline.offline_notice_auto_sync", )} {:else} {t("offline.server_unavailable_notice")} {/if} </div> {/if} {#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> {/if} </div> <Virtualizer bind:this={virtualizer} data={visibleRows} getKey={(row) => row.key} scrollRef={libraryElement} startMargin={headerHeight} 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} onToggle={row.toggle} onAdd={row.onAdd} /> {:else if row.kind === "item"} {@const state = nodeStates[row.key]} <LibraryTreeItem id={row.id} rowKey={row.key} label={row.label} 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)} onItemChange={(changes) => row.item && Object.assign(row.item, changes)} onUpdate={row.onUpdate} onDelete={row.onDelete} /> {/if} {/snippet} </Virtualizer></nav><!-- 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}
<style>#library { flex: 1; overflow-y: auto; padding: 0 1rem 0.5rem;}.library-header { display: grid; gap: 0.5rem; padding-block-start: 0.5rem;}.source-tabs { display: flex; flex-direction: column; gap: 0.5rem; & > .tab { min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; background-color: var(--bg-layer); border-radius: var(--radius-sm); corner-shape: squircle; padding-inline: 0.5rem; text-align: start; white-space: nowrap;
&.active { opacity: 1; background-color: var(--bg-highlight); color: var(--text-highlight); } }}.search-help { position: fixed; z-index: 30; top: 0; left: 0; inline-size: max-content; max-inline-size: min(45rem, calc(100vw - 2rem)); padding-block: 0.25rem; padding-inline: 0.5rem; font-size: smaller; border: var(--border); background-color: Canvas; color: CanvasText; text-wrap: pretty; overflow-wrap: anywhere; box-shadow: 0 2px 10px rgb(0 0 0 / 0.2); pointer-events: none; border-radius: var(--radius-md); corner-shape: squircle; overflow-x: hidden;}:global(html.transparency) .search-help { background-color: color-mix(Canvas 80%, transparent); backdrop-filter: blur(16px);}input { inline-size: 100%;}#library > :global(ul) { padding: 0; margin: 0; list-style: none;}:global(html.dynamic-colors) input { background-color: var(--bg-layer); color: var(--text); border: var(--border); border-radius: var(--radius-md); corner-shape: squircle;}:global(html.dynamic-colors) input::placeholder { color: var(--text); opacity: 0.5;}.library-statuses { display: grid; gap: 0.5rem;
[role="status"] { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
& > button { display: flex; align-items: center; } }}.unsynced-changes { inline-size: 100%;}</style>