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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555<script lang="ts">import { cubicOut } from "svelte/easing";import { slide } from "svelte/transition";import { getSourceApi, isRadio, type LyricsResponse, songArtId, songArtist,} from "./client.svelte.js";import { t } from "./languages/translate.svelte.js";import { entryMotion, fadeImage, motionEnabled } from "./motion.js";import { store } from "./offline/offlineManager.svelte.js";import { player } from "./player.svelte.js";import { queue, requestQueueScroll } from "./queue.svelte.js";import { settings } from "./settings.svelte.js";import { ui } from "./ui.svelte.js";import VisualizerHost from "./VisualizerHost.svelte";
type LyricLine = { time: number; text: string };
const parseSyncedLyrics = (text: string) => text .split("\n") .map((line) => { const match = line.match(/\[(\d+):(\d+)\.(\d+)\](.*)/); return match ? { time: +match[1] * 60 + +match[2] + +match[3] / 10 ** match[3].length, text: match[4].trim(), } : null; }) .filter((line): line is LyricLine => !!line?.text) .sort((a, b) => a.time - b.time);
function trackChangeDuration() { return motionEnabled() ? entryMotion.duration * 2 : 0;}
function slideTrackIn(_: Element, direction: 1 | -1) { return { duration: trackChangeDuration(), easing: cubicOut, css: (t: number) => `transform: translateX(${(1 - t) * direction * 150}%) scale(${0.75 + t * 0.25}); opacity: ${t};`, };}
function slideTrackOut(_: Element, direction: 1 | -1) { return { duration: trackChangeDuration(), easing: cubicOut, css: (t: number, u: number) => `position: absolute; transform: translateX(${-u * direction * 150}%) scale(${0.75 + t * 0.25}); opacity: ${t};`, };}
let trackSlideDirection = $state<1 | -1>(1);const trackKey = (track: { id: string; sourceId?: string } | null) => track ? `${track.sourceId || ""}:${track.id}` : undefined;let previousTrackKey = trackKey(player.track);let previousQueuePosition = queue.pos;let lyrics = $state({ trackKey: undefined as string | undefined, full: "", synced: [] as LyricLine[],});let showLyrics = $state(false);const artId = $derived(player.track ? songArtId(player.track) : "");
let currentLineIndex = $derived( lyrics.synced.findLastIndex((line) => line.time <= player.time),);let currentLine = $derived(lyrics.synced[currentLineIndex]?.text || "");let showSyncedLyrics = $derived( settings.lyrics && lyrics.trackKey === trackKey(player.track) && !!currentLine && !showLyrics,);
async function loadLyrics() { const track = player.track; lyrics.trackKey = undefined; lyrics.full = ""; lyrics.synced = []; if (!track || isRadio(track)) return;
const currentTrackKey = trackKey(track); let full = ""; let synced: LyricLine[] = [];
try { const applyLyrics = (response: LyricsResponse) => { const structuredLyrics = response.lyricsList?.structuredLyrics?.[0]; const lines = (structuredLyrics?.line ?? []) .map((line) => ({ time: +line.start / 1000, text: line.value.trim(), })) .filter((line) => line.text); const rawLyrics = (typeof response.lyrics === "object" ? response.lyrics?.value : response.lyrics) ?? ""; if (structuredLyrics?.synced) { synced = lines; } full = rawLyrics || lines.map((line) => line.text).join("\n"); if (!synced.length && rawLyrics) synced = parseSyncedLyrics(rawLyrics); };
const localLyrics = (await store.getSong(track.id, track.sourceId))?.lyrics || null; if (localLyrics) { applyLyrics(localLyrics); }
if (!full) { try { const response = track.sourceId ? await getSourceApi(track.sourceId).getLyricsBySongId(track.id) : null; if (response) applyLyrics(response); } catch {} }
if (!full) { if (!track.sourceId) return; const response = await getSourceApi(track.sourceId).getLyrics( songArtist(track), track.title, ); if (response) applyLyrics(response); } } catch (err) { console.error("failed to load lyrics:", err); }
if (trackKey(player.track) !== currentTrackKey) return; lyrics.trackKey = currentTrackKey; lyrics.full = full; lyrics.synced = synced;}
$effect.pre(() => { const currentTrackKey = trackKey(player.track); if ( currentTrackKey && previousTrackKey && currentTrackKey !== previousTrackKey ) { trackSlideDirection = queue.pos < previousQueuePosition ? -1 : 1; } previousTrackKey = currentTrackKey; previousQueuePosition = queue.pos;});
$effect(() => { if (trackKey(player.track)) void loadLyrics(); else { lyrics.trackKey = undefined; lyrics.full = ""; lyrics.synced = []; }});
let lyricsEl = $state<HTMLElement>();let syncedLyricsEl = $state<HTMLElement>();let previousSyncedLyricsHeight = 0;let syncedLyricsHeightAnimation: Animation | undefined;let lyricsHovered = $state(false);
$effect.pre(() => { currentLine; showSyncedLyrics; if (!motionEnabled()) return; previousSyncedLyricsHeight = lyrics.trackKey === trackKey(player.track) ? (syncedLyricsEl?.offsetHeight ?? 0) : 0;});
$effect(() => { trackKey(player.track); currentLine; showSyncedLyrics; const element = syncedLyricsEl; syncedLyricsHeightAnimation?.cancel(); syncedLyricsHeightAnimation = undefined; if (!element || !motionEnabled()) return;
const previousHeight = previousSyncedLyricsHeight; const nextHeight = element.offsetHeight; if (Math.abs(previousHeight - nextHeight) < 0.5) return;
const animation = element.animate( [{ blockSize: `${previousHeight}px` }, { blockSize: `${nextHeight}px` }], { duration: entryMotion.duration, easing: "cubic-bezier(0.33, 1, 0.68, 1)", }, ); syncedLyricsHeightAnimation = animation; animation.onfinish = animation.oncancel = () => { if (syncedLyricsHeightAnimation === animation) syncedLyricsHeightAnimation = undefined; };});
$effect(() => { if (currentLineIndex !== -1 && lyricsEl && !lyricsHovered) { lyricsEl.querySelector(".current-line")?.scrollIntoView({ behavior: motionEnabled() ? "smooth" : "auto", block: "center", }); }});
$effect(() => { trackKey(player.track); if (lyricsEl) { lyricsEl.scrollTop = 0; }});</script>
{#if player.track} <div class="now-playing-container" transition:slide={{ duration: ui.miniplayer ? 0 : trackChangeDuration(), }} > <section id="now-playing" aria-label={t("state.now_playing")}> {#if settings.visualizerLocation === "now-playing"} <VisualizerHost /> {/if} <div class="track-stage"> {#key trackKey(player.track)} <div class="track-content" in:slideTrackIn={trackSlideDirection} out:slideTrackOut={trackSlideDirection} > <div class="track-media"> <button type="button" tabindex="-1" class="track-artwork artwork" class:pressable={!showLyrics} class:pressable--gentle={!showLyrics} aria-label={t("common.lyrics")} aria-pressed={showLyrics} onclick={() => (showLyrics = !showLyrics)} > {#if artId} <img fetchpriority="low" loading="lazy" alt="" decoding="async" src={player.track.sourceId ? getSourceApi(player.track.sourceId).getCoverArt(artId, 512) : ""} class="artwork-image" {@attach fadeImage} > {/if} {#if settings.visualizerLocation === "artwork"} <VisualizerHost /> {/if} {#if showLyrics} <span bind:this={lyricsEl} role="presentation" class="lyrics" class:synced={lyrics.synced.length > 0} onpointerenter={(event) => { if (event.pointerType === "mouse") lyricsHovered = true; }} onpointerleave={(event) => { if (event.pointerType === "mouse") lyricsHovered = false; }} > {#if lyrics.synced.length} {#each lyrics.synced as line, index} <span class:current-line={index === currentLineIndex}> {line.text} </span> {/each} {:else} {lyrics.full || t("message.no_lyrics")} {/if} </span> {/if} </button> </div> <button type="button" tabindex="-1" class="track-info pressable pressable--gentle" onclick={() => requestQueueScroll(queue.pos, { center: true, smooth: true })} > <span class="title" title={player.track.title}> {player.track.title} </span> <span class="artist text-footnote" title={songArtist(player.track)} > {songArtist(player.track)} </span> {#if settings.lyrics} <span bind:this={syncedLyricsEl} class="synced-lyrics text-footnote" > {#if showSyncedLyrics} ♪ {currentLine} {/if} </span> {/if} {#if !settings.scrobbling && settings.scrobblingStatus} <span class="text-footnote"> {t("message.scrobbling_disabled")} </span> {/if} </button> </div> {/key} </div> </section> </div>{/if}
<style>.track-artwork { border-radius: var(--radius-md); corner-shape: squircle;}
#now-playing { border-block-start: var(--border-subtle); padding: 1rem; display: flex; flex-direction: column; align-items: center; text-align: center; justify-content: center; position: relative; overflow: clip;
.track-stage { position: relative; display: grid; inline-size: 100%; min-inline-size: 0; }
.track-content { grid-area: 1 / 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; inline-size: 100%; min-inline-size: 0; }
.track-media { display: contents; }
.track-artwork { position: relative; inline-size: 10rem; max-inline-size: 100%; aspect-ratio: 1; overflow: clip;
.lyrics { position: absolute; inset: 0; z-index: 1; display: flex; flex-direction: column; gap: 0.5rem; padding: 1rem; white-space: pre-wrap; text-wrap: pretty; overflow-wrap: anywhere; overflow-y: auto; color: var(--text); font-style: italic; font-size: smaller;
:where(.track-artwork:not(:hover)) & { scrollbar-width: none;
&::-webkit-scrollbar { display: none; } }
&.synced > :not(.current-line) { opacity: 0.5; } &.synced > .current-line { opacity: 1; } }
&[aria-pressed="true"] > .artwork-image { opacity: 0.125; }
:global(html.transparency) & { &[aria-pressed="true"] > .artwork-image { opacity: 0.25; filter: blur(16px); } } }
.track-info { inline-size: 100%; min-inline-size: 0; text-align: inherit; > span { display: block; } :is(.title, .artist) { white-space: nowrap; overflow-x: clip; text-overflow: ellipsis; }
.title { font-weight: bold; }
.synced-lyrics { font-style: italic; overflow: clip; text-wrap: pretty; } }
:global(html:not(.miniplayer)) & { @media (width <= 32rem) { text-align: start; padding-block: 0.5rem;
.track-content { flex-direction: row; }
.track-artwork { inline-size: 4rem; flex-shrink: 0; } .track-info { --button-transform-origin: left center; } :global(:dir(rtl)) & { .track-info { --button-transform-origin: right center; } } }
@media (width <= 16rem) { .track-artwork { inline-size: 3rem; } }
@media (height <= 24rem) { .track-artwork { display: none; } } }
:global(html.miniplayer) & { border-block-start: none; min-inline-size: 0;
.track-stage { flex: 1; min-block-size: 0; }
.track-content { block-size: 100%; }
.track-media { display: grid; place-items: center; block-size: 100%; inline-size: 100%; container-type: size; }
.track-artwork { block-size: min(100cqi, 100cqb); inline-size: min(100cqi, 100cqb); }
@media (height <= 12rem) { align-items: center; justify-content: flex-start; text-align: start;
.track-content { flex-direction: row; align-items: center; justify-content: flex-start; }
.track-media { align-self: stretch; inline-size: auto; aspect-ratio: 1; flex: 0 0 auto; }
.track-artwork { block-size: 100%; inline-size: auto; aspect-ratio: 1; }
.track-info { flex: 1; inline-size: 0; min-inline-size: 0; --button-transform-origin: left center; } :global(:dir(rtl)) & { .track-info { --button-transform-origin: right center; } } } }}</style>