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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988<script lang="ts">import CoverBackground from "../CoverBackground.svelte";import { type Album, type AlbumInfo, type Artist, type ArtistInfo, asArray, canPlayTrack, getSourceApi, hasSourceExtension, type Song, songArtId, songArtist,} from "../client.svelte.js";import ContextMenu, { type Item } from "../components/ContextMenu.svelte";import MediaAction from "../components/MediaAction.svelte";import { retryImageWithoutCors } from "../image.js";import LibraryLabel from "../LibraryLabel.svelte";import LibraryTreeItem from "../LibraryTreeItem.svelte";import { t } from "../languages/translate.svelte.js";import { type ChildFactory, isSong, itemKey, itemLabel, type LibraryItem, recursiveSongs,} from "../library.svelte.js";import { animateTreeExpansion, captureTreeItemPositions, fadeImage,} from "../motion.js";import { insertAt, queue } from "../queue.svelte.js";import { scrollFade } from "../scrollFade.js";import { settings } from "../settings.svelte.js";import { sources } from "../sources.svelte.js";import { getDominantColor } from "../theme.svelte.js";import { transparencyEnabled } from "../transparency.js";import { ui } from "../ui.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(transparencyEnabled());let artistAlbums = $state<Album[]>([]);let artistId = $state("");let artistLabel = $state("");let albumLabel = $state("");let albumId = $state("");let showAllTopSongs = $state(false);let error = $state("");let loading = $state(false);let modalTheme = $state<{ bg: string; text: string; dark: boolean } | null>( null,);let requestId = 0;let menu = $state<{ x: number; y: number; items: Item[]; keyboard?: boolean;} | null>(null);let sections = $state({ albums: true, similar: true });let miniLibrary = $state<HTMLElement>();
const infoArtworkSize = 48;type MiniType = "artist" | "album" | "song";type MiniNode = { expanded: boolean; items: LibraryItem[] | null; loading: boolean; actionBusy: boolean;};let miniNodes = $state<Record<string, MiniNode>>({});
const sourceId = $derived(ui.infoItem?.sourceId || sources.activeId);const sourceApi = $derived(sourceId ? getSourceApi(sourceId) : null);const hasSonicSimilarity = $derived( !!sourceId && hasSourceExtension(sourceId, "sonicsimilarity"),);const infoApi = () => { if (!sourceApi) throw new Error("Library required"); return sourceApi;};
const emptyMiniNode: MiniNode = { expanded: 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 toggleSection = (section: keyof typeof sections, key: string) => { const positions = !sections[section] ? captureTreeItemPositions(miniLibrary) : undefined; sections[section] = !sections[section]; if (sections[section]) animateTreeExpansion(miniLibrary, key, positions);};
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) { 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, fetcher: (() => Promise<LibraryItem[]>) | undefined, item: LibraryItem, childFactory: ChildFactory | undefined, asNext: boolean,) => { const index = asNext ? queue.pos + 1 : queue.tracks.length; if (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 toggleInfoStar = async ( item: Album | Artist | Song, type: "album" | "artist" | "song",) => { const starred = !!item.starred; const params = type === "album" ? { albumId: item.id } : type === "artist" ? { artistId: item.id } : { id: item.id }; try { if (starred) await infoApi().unstar(params); else await infoApi().star(params); item.starred = starred ? undefined : new Date().toISOString(); } catch (err) { console.error(err); }};
const setInfoRating = async (item: Album | Artist | Song, rating: number) => { try { await infoApi().setRating(item.id, rating); item.userRating = rating; } catch (err) { console.error("failed to set rating:", err); }};
const infoImage = (info: AlbumInfo | ArtistInfo, size: number) => { const image = (size <= infoArtworkSize ? info.smallImageUrl : undefined) || (size <= infoArtworkSize * 2 ? info.mediumImageUrl : undefined) || info.largeImageUrl || info.mediumImageUrl || info.smallImageUrl; if (!image) return; try { const url = new URL(image); if (url.searchParams.has("size")) { url.searchParams.set("size", String(size)); return url.href; } } catch {} return image;};const applyModalTheme = async ( event: Event, source: AlbumInfo | ArtistInfo | Song | null,) => { const isCurrent = () => source === (albumInfo ?? artistInfo) || source === songItem; if ( !settings.dynamicColors || !(event.currentTarget instanceof HTMLImageElement) || !isCurrent() ) return; try { const dominant = await getDominantColor(event.currentTarget); if (!settings.dynamicColors || !isCurrent()) return; modalTheme = { bg: dominant.css(), text: dominant.textColor, dark: dominant.isDark, }; } catch {}};
$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 = ""; showAllTopSongs = false; miniNodes = {}; sections = { albums: true, similar: true }; error = ""; loading = true; modalTheme = null; menu = null; void (async () => { try { const response = await infoApi().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-songs"] = { ...emptyMiniNode, expanded: true, items: asArray(album.song), }; if (item.type === "artist" && artistId) void toggleMiniNode( "info-top-songs", () => infoApi().getTopSongs(artistLabel, artistId), true, ); if (nextSong && hasSonicSimilarity) void toggleMiniNode( "info-sonic-similar", () => infoApi().getSonicSimilarTracks(nextSong.id), true, ); loading = false;
const [artistResult, albumResult] = await Promise.allSettled([ item.type === "artist" ? infoApi().getArtistInfo2(item.id) : Promise.resolve(null), item.type === "album" ? infoApi().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; } })();});
$effect(() => { if (!settings.dynamicColors) modalTheme = null;});</script>
{#snippet externalLinks( info: { musicBrainzId?: string; lastFmUrl?: string }, type: "artist" | "recording" | "release",)} {#if info.musicBrainzId || info.lastFmUrl} <div class="text-footnote"> {#if info.musicBrainzId} <a target="_blank" rel="noreferrer" href={`https://musicbrainz.org/${type}/${info.musicBrainzId}`} > MusicBrainz </a> {/if} {#if info.lastFmUrl} {#if info.musicBrainzId} {" "} {/if} <a target="_blank" rel="noreferrer" href={info.lastFmUrl}>Last.fm</a> {/if} </div> {/if}{/snippet}
{#snippet libraryItem( type: MiniType, item: LibraryItem, fetcher: (() => Promise<LibraryItem[]>) | undefined, rowKey: string, depth = 0, artworkUrl?: string,)} {@const track = type === "song" && isSong(item) ? item : undefined} {@const childFactory = type === "artist" ? (child: LibraryItem) => () => infoApi().getAlbum(child.id) : undefined} <LibraryTreeItem id={item.id} {rowKey} label={itemLabel(item)} {type} {item} {track} {artworkUrl} {depth} expanded={miniNode(rowKey).expanded} busy={miniNode(rowKey).loading || miniNode(rowKey).actionBusy} primaryUnavailable={!fetcher} onPrimaryAction={() => void toggleMiniNode(rowKey, fetcher)} onAdd={(asNext) => void addToQueue(rowKey, fetcher, item, childFactory, asNext)} onMenu={(nextMenu) => (menu = nextMenu)} onItemChange={(changes) => Object.assign(item, changes)} /> {#if miniNode(rowKey).expanded && miniNode(rowKey).items} {@const childType = type === "artist" ? "album" : "song"} {#each miniNode(rowKey).items ?? [] as child, index (itemKey(rowKey, childType, child.id, index))} {@const childFetcher = childType === "album" ? () => infoApi().getAlbum(child.id) : undefined} {@render libraryItem( childType, child, childFetcher, itemKey(rowKey, childType, child.id, index), depth + 1, )} {/each} {/if}{/snippet}
{#snippet infoArtwork( info: AlbumInfo | ArtistInfo, onload: (event: Event) => void,)} {@const image = infoImage(info, infoArtworkSize)} {@const image2x = infoImage(info, infoArtworkSize * 2)} {#if image} <img class="artwork-image" alt="" crossorigin={settings.dynamicColors && !image.startsWith("blob:") ? "anonymous" : undefined} src={image} srcset={image2x && image2x !== image ? `${image} 1x, ${image2x} 2x` : undefined} {onload} {@attach retryImageWithoutCors} {@attach fadeImage} > {/if}{/snippet}
{#snippet itemActions( item: Album | Artist | Song, type: "album" | "artist" | "song",)} <div class="info-actions"> <MediaAction kind="favorite" starred={!!item.starred} subtle onfavorite={() => toggleInfoStar(item, type)} /> {" "} <MediaAction kind="rating" rating={item.userRating ?? 0} subtle onrating={(value) => setInfoRating(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 artistInfo || albumInfo || songItem} {@const backgroundInfo = artistInfo ?? albumInfo} {@const artId = songItem && songArtId(songItem)} {@const backgroundImage = backgroundInfo ? infoImage(backgroundInfo, 512) : artId ? infoApi().getCoverArt(artId, 512) : 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> {#if artistItem} <article> <header> {#if artistInfo} {@const info = artistInfo} {@render infoArtwork( info, (event) => applyModalTheme(event, info), )} {/if} <div> <h2>{artistLabel}</h2> <div class="info-meta text-footnote"> {@render itemActions(artistItem, "artist")} </div> {#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} {@const artwork = infoApi().getCoverArt(artId, infoArtworkSize)} {@const artwork2x = infoApi().getCoverArt( artId, infoArtworkSize * 2, )} <img class="artwork-image" alt="" crossorigin={settings.dynamicColors && !artwork.startsWith("blob:") ? "anonymous" : undefined} src={artwork} srcset={`${artwork} 1x, ${artwork2x} 2x`} onload={(event) => applyModalTheme(event, songItem)} {@attach retryImageWithoutCors} {@attach fadeImage} > {/if} <div> <h2>{songItem.title}</h2> <div class="info-meta text-footnote"> {#if albumLabel} {#if albumId} <button type="button" onclick={() => (ui.infoItem = { type: "album", id: albumId, label: albumLabel, sourceId: sourceId || undefined, })} > {albumLabel} </button> {:else} <span>{albumLabel}</span> {/if} {/if} {#if artistLabel} <span> • </span> {#if artistId} <button type="button" onclick={() => (ui.infoItem = { type: "artist", id: artistId, label: artistLabel, sourceId: sourceId || undefined, })} > {artistLabel} </button> {:else} <span>{artistLabel}</span> {/if} {/if} {#if songItem.year !== undefined} <span> • </span> <span>{songItem.year}</span> {/if} <span> • </span> {@render itemActions(songItem, "song")} </div> {@render externalLinks(songItem, "recording")} </div> </header> </article> {/if} {#if albumItem} <article> <header> {#if albumInfo} {@const info = albumInfo} {@render infoArtwork( info, (event) => applyModalTheme(event, info), )} {/if} <div> <h2>{albumLabel}</h2> <div class="info-meta text-footnote"> {#if artistLabel} {#if artistId} <button type="button" onclick={() => (ui.infoItem = { type: "artist", id: artistId, label: artistLabel, sourceId: sourceId || undefined, })} > {artistLabel} </button> {:else} <span>{artistLabel}</span> {/if} {/if} {#if albumItem.year !== undefined} <span> • </span> <span>{albumItem.year}</span> {/if} <span> • </span> {@render itemActions(albumItem, "album")} </div> {#if albumInfo} {@render externalLinks(albumInfo, "release")} {/if} </div> </header> {#if albumInfo?.notes} <p>{albumInfo.notes}</p> {/if} </article> {/if} </div> {#if ui.infoItem?.type === "artist" || albumItem || artistAlbums.length || artistInfo?.similarArtist?.length || (songItem && hasSonicSimilarity)} <aside bind:this={miniLibrary} class:scroll-fade={scrollFadeEnabled} aria-label={t("common.library")} use:scrollFade={scrollFadeEnabled} > {#if songItem && hasSonicSimilarity} {@const song = songItem} {@const similarSongsKey = "info-sonic-similar"} {@const similarSongs = miniNode(similarSongsKey)} {#if similarSongs.items?.length} <LibraryLabel rowKey={similarSongsKey} label={t("common.similar_songs")} expanded={similarSongs.expanded} onAdd={() => addSongsToQueue(similarSongs.items ?? [])} onToggle={() => void toggleMiniNode( similarSongsKey, () => infoApi().getSonicSimilarTracks(song.id), )} /> {#if similarSongs.expanded} <div> {#each similarSongs.items as song, index (itemKey(similarSongsKey, "song", song.id, index))} {@render libraryItem( "song", song, undefined, itemKey(similarSongsKey, "song", song.id, index), )} {/each} </div> {/if} {/if} {/if} {#if ui.infoItem?.type === "artist" && artistId} {@const topSongsKey = "info-top-songs"} {@const topSongs = miniNode(topSongsKey)} {#if topSongs.items?.length} {@const visibleTopSongs = showAllTopSongs ? topSongs.items : topSongs.items.slice(0, 10)} <LibraryLabel rowKey={topSongsKey} label={t("common.top_songs")} expanded={topSongs.expanded} onAdd={() => addSongsToQueue(visibleTopSongs)} onToggle={() => void toggleMiniNode( topSongsKey, () => infoApi().getTopSongs(artistLabel, artistId), )} /> {#if topSongs.expanded} <div> {#each visibleTopSongs as song, index (itemKey(topSongsKey, "song", song.id, index))} {@render libraryItem( "song", song, undefined, itemKey(topSongsKey, "song", song.id, index), )} {/each} </div> {#if !showAllTopSongs && topSongs.items.length > 10} <LibraryTreeItem id={`${topSongsKey}/show-more`} rowKey={itemKey(topSongsKey, "show-more", "more")} label={t("common.show_more")} type="show-more" depth={0} expanded={false} busy={false} primaryUnavailable={false} onPrimaryAction={() => { showAllTopSongs = true; }} /> {/if} {/if} {/if} {/if} {#if albumItem && albumId} {@const songsKey = "info-songs"} {@const songs = miniNode(songsKey)} <LibraryLabel rowKey={songsKey} label={t("common.songs")} expanded={songs.expanded} onAdd={() => addSongsToQueue(songs.items ?? [])} onToggle={() => void toggleMiniNode(songsKey, () => infoApi().getAlbum(albumId))} /> {#if songs.expanded && songs.items} <div> {#each songs.items as song, index (itemKey(songsKey, "song", song.id, index))} {@render libraryItem( "song", song, undefined, itemKey(songsKey, "song", song.id, index), )} {/each} </div> {/if} {/if} {#if artistAlbums.length} <LibraryLabel rowKey="info-albums" label={t("common.albums")} expanded={sections.albums} onToggle={() => toggleSection("albums", "info-albums")} /> {#if sections.albums} <div> {#each artistAlbums as album, index (itemKey("info-albums", "album", album.id, index))} {@render libraryItem( "album", album, () => infoApi().getAlbum(album.id), itemKey("info-albums", "album", album.id, index), )} {/each} </div> {/if} {/if} {#if artistInfo?.similarArtist?.length} <LibraryLabel rowKey="info-similar" label={t("common.similar_artists")} expanded={sections.similar} onToggle={() => toggleSection("similar", "info-similar")} /> {#if sections.similar} <div> {#each artistInfo.similarArtist as artist, index (itemKey("info-similar", "artist", artist.id, index))} {@render libraryItem( "artist", artist, () => infoApi().getArtistAlbums(artist.id), itemKey("info-similar", "artist", artist.id, index), 0, artist.artistImageUrl, )} {/each} </div> {/if} {/if} </aside> {/if} </div> {#if details !== null} <details> <summary>API</summary> <pre>{JSON.stringify( { item: details, artistInfo, albumInfo, artistAlbums }, null, 2, )}</pre> </details> {/if} {/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),:global(dialog.info-modal > header) { background-color: var(--info-bg, Canvas); color: var(--info-text, CanvasText);}:global(dialog.info-modal) { display: flex; flex-direction: column; min-block-size: 16rem;}:global(dialog.info-modal a) { color: inherit;}section { position: relative; display: flex; flex: 1; inline-size: min(80dvw, 48rem, var(--viewport-inline-size)); block-size: min(70dvh, var(--viewport-block-size)); min-block-size: 0; overflow: clip; container-type: inline-size;}.info-content { display: flex; flex-direction: column; flex: 1; gap: 1rem; min-block-size: 0; min-inline-size: 0; padding: 1rem; position: relative; z-index: 1; overflow: auto;}.info-layout { display: flex; flex-direction: column; flex: 1 0 auto; gap: 1rem; min-block-size: 0; min-inline-size: 0;}@container (width > 40rem) { .info-layout:has(> aside) { position: relative; padding-inline-end: calc(var(--info-sidebar-width) + 1rem); } .info-layout > aside { position: absolute; inset-block: 0; inset-inline-end: 0; inline-size: var(--info-sidebar-width); overflow-y: auto; }}@media (width <= 32rem) { :global(dialog.info-modal) { inline-size: var(--viewport-inline-size); block-size: var(--viewport-block-size); min-block-size: 0; } 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; 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-meta { opacity: 1; }
.info-meta > button { text-align: start; }
.info-actions { display: inline;
:global(button) { vertical-align: middle; } :global(fieldset) { display: inline-flex; vertical-align: middle; } } }}.info-layout > div { display: flex; flex: 1; flex-direction: column; gap: 1rem; min-inline-size: 0;}section > :global(.info-background) { position: absolute; inset-inline: 0; inset-block-start: 0; z-index: 0; inline-size: 100%; block-size: 100%; transform-origin: center top; mask-image: linear-gradient(to bottom, black, transparent 90%);}section > :global(.info-background) > :global(img) { object-position: center top;}</style>