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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948<script lang="ts">import { api, asArray, canPlayTrack, extensions, type StarredItem, type StarredType, setRating, songArtist, toggleStar,} from "../api/client.svelte.js";
import type { Album, AlbumInfo, Artist, ArtistInfo, Song,} from "../api/types.js";import { settings } from "../app/settings.svelte.js";import { type ArtworkTheme, getArtworkTheme, resolveBackground,} from "../app/theme.svelte.js";import ContextMenu, { type Item } from "../components/ContextMenu.svelte";import MediaAction from "../components/MediaAction.svelte";import { t } from "../languages/translate.svelte.js";import LibraryLabel from "../library/LibraryLabel.svelte";import LibraryTreeItem from "../library/LibraryTreeItem.svelte";import { type ChildFactory, isSong, itemKey, itemLabel, type LibraryItem, recursiveSongs, yearDetail,} from "../library/store.svelte.js";import { insertAt, queue, queueInsertIndex } from "../playback/queue.svelte.js";import { retryImageWithoutCors, songArtId } from "../ui/artwork.svelte.js";import CoverBackground from "../ui/CoverBackground.svelte";import { animateTreeExpansion, captureTreeItemPositions, fadeImage, transparencyEnabled,} from "../ui/motion.svelte.js";import { scrollFade } from "../ui/scrollFade.js";import { ui } from "../ui/state.svelte.js";import Modal from "./Modal.svelte";
let details = $state<unknown>(null);let albumInfo = $state<AlbumInfo | null>(null);let albumItem = $state<Album | null>(null);let artistItem = $state<Artist | null>(null);let artistInfo = $state<ArtistInfo | null>(null);let songItem = $state<Song | null>(null);const scrollFadeEnabled = $derived(!settings.borders && transparencyEnabled());let artistAlbums = $state<Album[]>([]);let artistId = $state("");let artistLabel = $state("");let albumLabel = $state("");let albumId = $state("");let showAllSimilarArtists = $state(false);let error = $state("");let loading = $state(false);let modalArtworkTheme = $state<ArtworkTheme | null>(null);const modalTheme = $derived( modalArtworkTheme && settings.dynamicColors ? resolveBackground(modalArtworkTheme) : null,);let requestId = 0;let menu = $state<{ x: number; y: number; items: Item[]; keyboard?: boolean;} | null>(null);let similarArtistsExpanded = $state(true);let miniLibrary = $state<HTMLElement>();
const infoArtworkSize = 64;const previewCount = 10;type MiniNode = { expanded: boolean; showAll: boolean; items: LibraryItem[] | null; loading: boolean; actionBusy: boolean;};let miniNodes = $state<Record<string, MiniNode>>({});
const emptyMiniNode: MiniNode = { expanded: false, showAll: false, items: null, loading: false, actionBusy: false,};
const miniNode = (key: string) => miniNodes[key] ?? emptyMiniNode;
const ensureMiniNode = (key: string) => { const state = miniNodes[key]; if (state) return state; miniNodes[key] = { ...emptyMiniNode }; return miniNodes[key];};
const toggleSimilarArtists = () => { const positions = !similarArtistsExpanded ? captureTreeItemPositions(miniLibrary) : undefined; similarArtistsExpanded = !similarArtistsExpanded; if (similarArtistsExpanded) animateTreeExpansion(miniLibrary, "info-similar", positions); else showAllSimilarArtists = false;};
const toggleMiniNode = async ( key: string, fetcher: (() => Promise<LibraryItem[]>) | undefined, open = false,) => { if (!fetcher) return; const request = requestId; const state = ensureMiniNode(key); if (state.loading) return; const positions = !state.expanded && !open ? captureTreeItemPositions(miniLibrary) : undefined; state.expanded = open || !state.expanded; if (!state.expanded) { state.showAll = false; return; } if (!state.items) { state.loading = true; try { const items = await fetcher(); if (request !== requestId) return; state.items = items; } catch (err) { console.error(err); } finally { state.loading = false; } } if (request !== requestId) return; if (state.expanded && state.items) animateTreeExpansion(miniLibrary, key, positions);};
const addToQueue = async ( key: string, type: StarredType, fetcher: (() => Promise<LibraryItem[]>) | undefined, item: LibraryItem, childFactory: ChildFactory | undefined, asNext: boolean,) => { const index = queueInsertIndex(asNext); if (type === "song" && isSong(item)) { if (canPlayTrack(item)) insertAt(index, item); return; } if (!fetcher) return; const state = ensureMiniNode(key); if (state.actionBusy) return; state.actionBusy = true; try { insertAt(index, await recursiveSongs(await fetcher(), childFactory)); } catch (err) { console.error(err); } finally { state.actionBusy = false; }};
const addSongsToQueue = (items: LibraryItem[]) => { const songs = items.flatMap((item) => isSong(item) && canPlayTrack(item) ? [item] : [], ); if (songs.length) insertAt(queue.tracks.length, songs);};
const applyModalTheme = async ( event: Event, source: Album | Artist | Song | null,) => { const isCurrent = () => source === artistItem || source === albumItem || source === songItem; if ( !settings.dynamicColors || !(event.currentTarget instanceof HTMLImageElement) || !isCurrent() ) return; const theme = await getArtworkTheme(event.currentTarget); if (!settings.dynamicColors || !isCurrent()) return; modalArtworkTheme = theme;};
$effect(() => { const item = ui.infoItem; const id = ++requestId; if (!item) { menu = null; return; }
details = null; albumInfo = null; albumItem = null; artistItem = null; songItem = null; artistInfo = null; artistAlbums = []; artistId = ""; artistLabel = ""; albumLabel = ""; albumId = ""; miniNodes = {}; showAllSimilarArtists = false; similarArtistsExpanded = true; error = ""; loading = true; modalArtworkTheme = null; menu = null; void (async () => { try { const response = await api.getInfo(item.type, item.id); if (id !== requestId) return; const nextSong = item.type === "song" ? response.song || null : null; const album = response.album || null; const artistResponse = item.type === "artist" ? response.artist || null : null; const nextArtistId = item.type === "artist" ? item.id : item.type === "album" ? album?.artistId : nextSong?.albumArtistId || nextSong?.artistId; const nextAlbumId = item.type === "album" ? item.id : nextSong?.albumId; details = response; albumItem = item.type === "album" ? album : null; artistItem = item.type === "artist" ? artistResponse : null; songItem = nextSong; artistId = nextArtistId || ""; albumId = nextAlbumId || ""; artistLabel = artistResponse?.name || album?.displayArtist || album?.artist || (nextSong ? songArtist(nextSong) : "") || (item.type === "artist" ? item.label : ""); albumLabel = album?.name || nextSong?.album || (item.type === "album" ? item.label : ""); artistAlbums = item.type === "artist" ? asArray(artistResponse?.album) : []; if (item.type === "album" && album?.song !== undefined) miniNodes["info-subject"] = { ...emptyMiniNode, expanded: true, items: asArray(album.song), }; else if (item.type === "artist") miniNodes["info-subject"] = { ...emptyMiniNode, expanded: true, items: artistAlbums, }; if (item.type === "artist" && artistId) void toggleMiniNode( "info-top-songs", () => api.getTopSongs(artistLabel, artistId), true, ); if (nextSong && extensions.has("sonicsimilarity")) void toggleMiniNode( "info-similar-songs", () => api.getSonicSimilarTracks(nextSong.id), true, ); else if (nextArtistId) void toggleMiniNode( "info-similar-songs", () => api.getSimilarSongs2(nextArtistId), true, ); loading = false;
const [artistResult, albumResult] = await Promise.allSettled([ item.type === "artist" ? api.getArtistInfo2(item.id) : Promise.resolve(null), item.type === "album" ? api.getAlbumInfo2(item.id) : Promise.resolve(null), ]); if (id !== requestId) return; artistInfo = artistResult.status === "fulfilled" ? artistResult.value : null; albumInfo = albumResult.status === "fulfilled" ? albumResult.value : null; } catch (err: unknown) { if (id === requestId) error = String(err); } finally { if (id === requestId) loading = false; } })();});</script>
{#snippet externalLinks( info: { musicBrainzId?: string; lastFmUrl?: string }, type: "artist" | "recording" | "release",)} {#if info.musicBrainzId || info.lastFmUrl} <small class="external-links"> {#if info.musicBrainzId} <a target="_blank" rel="noreferrer" href={`https://musicbrainz.org/${type}/${info.musicBrainzId}`} > MusicBrainz </a> {/if} {#if info.lastFmUrl} <a target="_blank" rel="noreferrer" href={info.lastFmUrl}>Last.fm</a> {/if} </small> {/if}{/snippet}
{#snippet infoNavButton( type: "album" | "artist", id: string, label: string,)} {#if id} <button type="button" onclick={() => { ui.infoItem = { type, id, label }; }} > {label} </button> {:else} <span>{label}</span> {/if}{/snippet}
{#snippet libraryItem( type: StarredType, item: LibraryItem, fetcher: (() => Promise<LibraryItem[]>) | undefined, rowKey: string, detail?: string, depth = 0,)} {@const track = type === "song" && isSong(item) ? item : undefined} {@const childFactory = type === "artist" ? (child: LibraryItem) => () => api.getAlbum(child.id) : undefined} {@const node = miniNode(rowKey)} <LibraryTreeItem id={item.id} {rowKey} label={itemLabel(item)} {detail} {type} {item} {track} {depth} expanded={node.expanded} busy={node.loading || node.actionBusy} primaryUnavailable={!fetcher} tabbable onPrimaryAction={() => void toggleMiniNode(rowKey, fetcher)} onAdd={(asNext) => void addToQueue(rowKey, type, fetcher, item, childFactory, asNext)} onMenu={(nextMenu) => (menu = nextMenu)} /> {#if node.expanded && node.items} {@const childType = type === "artist" ? "album" : "song"} {#each node.items as child, index (itemKey( rowKey, childType, child.id, index, ))} {@const childFetcher = childType === "album" ? () => api.getAlbum(child.id) : undefined} {@render libraryItem( childType, child, childFetcher, itemKey(rowKey, childType, child.id, index), childType === "album" ? yearDetail(child) : undefined, depth + 1, )} {/each} {/if}{/snippet}
{#snippet showMoreItem( keyPrefix: string, onShowAll: () => void,)} <LibraryTreeItem id={`${keyPrefix}/show-more`} rowKey={itemKey(keyPrefix, "show-more", "more")} label={t("common.show_more")} type="show-more" depth={0} expanded={false} busy={false} primaryUnavailable={false} tabbable onPrimaryAction={onShowAll} />{/snippet}
{#snippet songListSection( listKey: string, label: string, fetcher: () => Promise<Song[]>,)} {@const node = miniNode(listKey)} {#if node.items?.length} {@const visibleSongs = node.showAll ? node.items : node.items.slice(0, previewCount)} <LibraryLabel rowKey={listKey} {label} expanded={node.expanded} tabbable onAdd={() => addSongsToQueue(node.items ?? [])} onToggle={() => void toggleMiniNode(listKey, fetcher)} /> {#if node.expanded} <div> {#each visibleSongs as song, index (itemKey( listKey, "song", song.id, index, ))} {@render libraryItem( "song", song, undefined, itemKey(listKey, "song", song.id, index), )} {/each} </div> {#if !node.showAll && node.items.length > previewCount} {@render showMoreItem(listKey, () => { ensureMiniNode(listKey).showAll = true; })} {/if} {/if} {/if}{/snippet}
{#snippet infoArtwork( coverArtId: string, onload: (event: Event) => void,)} {@const artwork = api.getCoverArt(coverArtId, infoArtworkSize)} {@const artwork2x = api.getCoverArt(coverArtId, infoArtworkSize * 2)} <img class="artwork-image" alt="" fetchpriority="low" decoding="async" crossorigin={settings.dynamicColors && !artwork.startsWith("blob:") ? "anonymous" : undefined} src={artwork} srcset={artwork.startsWith("blob:") ? undefined : `${artwork} 1x, ${artwork2x} 2x`} {onload} {@attach retryImageWithoutCors} {@attach (element) => fadeImage(element, coverArtId)} >{/snippet}
{#snippet itemActions( item: StarredItem, type: StarredType,)} <div class="info-actions"> <MediaAction kind="favorite" starred={!!item.starred} subtle onfavorite={() => void toggleStar(item, type)} /> <MediaAction kind="rating" rating={item.userRating ?? 0} subtle onrating={(value) => void setRating(item, value)} /> </div>{/snippet}
<Modal open={!!ui.infoItem} onclose={() => { ui.infoItem = null; }} class="info-modal" style={modalTheme ? `--info-bg: ${modalTheme.bg}; --info-text: ${modalTheme.text}; color-scheme: ${modalTheme.dark ? "dark" : "light"}` : ""} title={`${t("common.info")}: ${ui.infoItem?.label ?? ""}`}> <section> {#if artistItem || albumItem || songItem} {@const backgroundId = artistItem?.coverArt || albumItem?.coverArt || (songItem && songArtId(songItem)) || undefined} {@const backgroundSize = settings.coverBackgroundBlur > 0 ? 128 : 512} {@const backgroundImage = backgroundId ? api.getCoverArt(backgroundId, backgroundSize) : undefined} {#if backgroundImage} <CoverBackground className="info-background" src={backgroundImage} /> {/if} {/if} <div class="info-content"> {#if loading} <p>{t("common.busy")}</p> {:else if error} <p>{error}</p> {:else if !artistItem && !albumItem && !songItem && details !== null} <pre>{JSON.stringify(details, null, 2)}</pre> {:else} <div class="info-layout" style:--info-sidebar-width={`${settings.sidebarWidth}rem`} > <div class="info-main"> {#if artistItem} <article> <header> {#if artistItem.coverArt} {@const coverArt = artistItem.coverArt} {@render infoArtwork(coverArt, (event) => applyModalTheme(event, artistItem), )} {/if} <div> <h2>{artistLabel}</h2> {@render itemActions(artistItem, "artist")} {#if artistInfo} {@render externalLinks(artistInfo, "artist")} {/if} </div> </header> {#if artistInfo?.biography} <p>{artistInfo.biography}</p> {/if} </article> {/if} {#if songItem} {@const artId = songArtId(songItem)} <article> <header> {#if artId} {@render infoArtwork(artId, (event) => applyModalTheme(event, songItem), )} {/if} <div> <h2> {songItem.title} {#if songItem.year !== undefined} <span class="info-year">({songItem.year})</span> {/if} </h2> {#if albumLabel || artistLabel} <div class="info-meta"> {#if albumLabel} {@render infoNavButton("album", albumId, albumLabel)} {/if} {#if artistLabel} {@render infoNavButton( "artist", artistId, artistLabel, )} {/if} </div> {/if} {@render itemActions(songItem, "song")} {@render externalLinks(songItem, "recording")} </div> </header> </article> {/if} {#if albumItem} <article> <header> {#if albumItem.coverArt} {@const coverArt = albumItem.coverArt} {@render infoArtwork(coverArt, (event) => applyModalTheme(event, albumItem), )} {/if} <div> <h2> {albumLabel} {#if albumItem.year !== undefined} <span class="info-year">({albumItem.year})</span> {/if} </h2> {#if artistLabel} <div class="info-meta"> {@render infoNavButton("artist", artistId, artistLabel)} </div> {/if} {@render itemActions(albumItem, "album")} {#if albumInfo} {@render externalLinks(albumInfo, "release")} {/if} </div> </header> {#if albumInfo?.notes} <p>{albumInfo.notes}</p> {/if} </article> {/if} {#if details !== null} <details> <summary>API</summary> <pre >{JSON.stringify({ item: details, artistInfo, albumInfo, artistAlbums }, null, 2)}</pre> </details> {/if} </div> {#if albumItem || artistItem || songItem} <aside bind:this={miniLibrary} class:scroll-fade={scrollFadeEnabled} aria-label={t("common.library")} use:scrollFade={scrollFadeEnabled} > {#if albumItem && albumId} {@render libraryItem( "album", albumItem, () => api.getAlbum(albumId), "info-subject", )} {:else if artistItem && artistId} {@render libraryItem( "artist", artistItem, () => api.getArtistAlbums(artistId), "info-subject", artistItem.disambiguation?.trim() || undefined, )} {:else if songItem} {@render libraryItem( "song", songItem, undefined, "info-subject", )} {/if} {#if ui.infoItem?.type === "artist" && artistId} {@render songListSection( "info-top-songs", t("common.top_songs"), () => api.getTopSongs(artistLabel, artistId), )} {/if} {#if songItem && extensions.has("sonicsimilarity")} {@const song = songItem} {@render songListSection( "info-similar-songs", t("common.similar_songs"), () => api.getSonicSimilarTracks(song.id), )} {:else if artistId} {@render songListSection( "info-similar-songs", t("common.similar_songs"), () => api.getSimilarSongs2(artistId), )} {/if} {#if artistInfo?.similarArtist?.length} {@const visibleSimilarArtists = showAllSimilarArtists ? artistInfo.similarArtist : artistInfo.similarArtist.slice(0, previewCount)} <LibraryLabel rowKey="info-similar" label={t("common.similar_artists")} expanded={similarArtistsExpanded} tabbable onToggle={toggleSimilarArtists} /> {#if similarArtistsExpanded} <div> {#each visibleSimilarArtists as artist, index (itemKey( "info-similar", "artist", artist.id, index, ))} {@render libraryItem( "artist", artist, () => api.getArtistAlbums(artist.id), itemKey("info-similar", "artist", artist.id, index), artist.disambiguation?.trim() || undefined, 0, )} {/each} </div> {#if !showAllSimilarArtists && artistInfo.similarArtist.length > previewCount} {@render showMoreItem("info-similar", () => { showAllSimilarArtists = true; })} {/if} {/if} {/if} </aside> {/if} </div> {/if} </div> </section> {#if menu} <ContextMenu x={menu.x} y={menu.y} items={menu.items} keyboard={menu.keyboard} onclose={() => (menu = null)} /> {/if}</Modal>
<style>:global(dialog.info-modal) { background-color: var(--info-bg, Canvas); color: var(--info-text, CanvasText); display: flex; flex-direction: column;}:global(html.motion) :global(dialog.info-modal) { transition: background-color 300ms var(--ease-out-cubic);}section { position: relative; display: flex; flex: 1 1 auto; inline-size: min(80dvw, 48rem); block-size: min(80dvh, 32rem); min-block-size: 0; overflow: clip; container-type: inline-size;}.info-content { display: flex; flex-direction: column; flex: 1; min-block-size: 0; min-inline-size: 0; position: relative; z-index: 1; overflow-y: auto;}.info-layout { display: flex; flex-direction: column; flex: 1 0 auto; min-block-size: 0; min-inline-size: 0;}.info-main { display: flex; flex-direction: column; gap: 1rem; min-inline-size: 0; padding: 1rem;}.info-layout > aside { min-inline-size: 0; padding: 0.5rem 1rem 1rem; border-block-start: var(--border-subtle);}@container (width > 40rem) { .info-content { overflow: clip; } .info-layout { flex-direction: row; flex: 1 1 auto; overflow: clip; } .info-main { flex: 1; min-block-size: 0; overflow-y: auto; } .info-layout > aside { inline-size: var(--info-sidebar-width); flex-shrink: 0; min-block-size: 0; overflow-x: clip; overflow-y: auto; border-block-start: none; border-inline-start: var(--border-subtle); }}@media (width <= 32rem) { :global(dialog.info-modal) { inline-size: var(--viewport-inline-size); block-size: var(--viewport-block-size); } section { inline-size: 100%; block-size: 100%; }}pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere;}article { display: flex; flex-direction: column; gap: 1rem; min-inline-size: 0;
> p { margin-block: 0; white-space: pre-wrap; text-wrap: pretty; overflow-wrap: anywhere; }
> header { display: flex; align-items: center; gap: 1rem; min-inline-size: 0;
> img { inline-size: 4rem; block-size: 4rem; border-radius: var(--radius-sm); corner-shape: squircle; }
> div { display: flex; flex-direction: column; align-items: flex-start; min-inline-size: 0; }
h2 { margin: 0; font-size: 1rem; font-weight: bold; text-wrap: balance; overflow-wrap: anywhere; }
.info-year { font-size: smaller; font-weight: normal; opacity: 0.75; }
.info-meta { display: flex; flex-wrap: wrap; gap: 0.5ch; font-size: smaller; }
.info-meta > * + *::before { content: "•"; margin-inline-end: 0.5ch; opacity: 0.75; }
.info-meta > button { text-align: start; }
.external-links { display: flex; gap: 1ch; opacity: 0.75; }
.external-links a { color: inherit; }
.info-actions { display: flex; align-items: center; gap: 0.5rem; font-size: smaller; } }}.info-content > p,.info-content > pre { padding: 1rem;}section > :global(.info-background) { position: absolute; inset-inline: 0; inset-block-start: 0; z-index: 0; block-size: 100%; mask-image: linear-gradient(to bottom, black, transparent 90%);}section > :global(.info-background) > :global(img) { object-position: center top;}</style>