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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503<script lang="ts">import { isRadio, setRating, toggleStar } from "../api/client.svelte.js";import type { Song } from "../api/types.js";import { settings } from "../app/settings.svelte.js";import ContextMenu from "../components/ContextMenu.svelte";import MediaAction from "../components/MediaAction.svelte";import ProgressBar from "../components/ProgressBar.svelte";import Icon from "../icons/Icon.svelte";import { t } from "../languages/translate.svelte.js";import { formatTime, player, seek, toggle, toggleMute,} from "../playback/player.svelte.js";import { clear, getSortItems, hasVisibleQueueTracks, next, prev, queue,} from "../playback/queue.svelte.js";import { lazyComponent } from "../ui/lazy.svelte.js";import { ui } from "../ui/state.svelte.js";
let scrubTrackId: string | undefined;const startScrub = () => (scrubTrackId = player.track?.id);
const trackIsRadio = $derived(!!player.track && isRadio(player.track));const isWaveform = $derived(settings.progressBarStyle === "waveform");const durationSuffix = (total: number) => total > 0 ? `, ${formatTime(total)}` : "";const totalDurationLabel = $derived( durationSuffix( queue.tracks.reduce((sum, track) => sum + (track.duration || 0), 0), ),);const selectedDurationLabel = $derived( durationSuffix( queue.sel.reduce( (sum, index) => sum + (queue.tracks[index]?.duration || 0), 0, ), ),);let menu = $state<{ x: number; y: number;} | null>(null);let sortExpanded = $state(false);
const WaveformOverlay = lazyComponent( () => import("../playback/WaveformOverlay.svelte"),);
$effect(() => { if (isWaveform) WaveformOverlay.ensure();});</script>
<section id="controls" aria-label={t("common.controls")} class:time-disabled={!settings.controlTime} class:time-tabular={settings.controlTimeTabular} class:volume-disabled={!settings.controlVolume}> {#snippet favoriteButton( track: Song, )} <MediaAction kind="favorite" starred={!!track.starred} dimInactive tabindex={-1} showTitle onfavorite={() => toggleStar(track, "song")} > <Icon name="heart" /> </MediaAction> {/snippet}
{#if settings.controlRating && player.track && !trackIsRadio} {@const track = player.track} <div id="ratings"> {#if settings.controlFavorite} {@render favoriteButton(track)} {/if} <MediaAction kind="rating" rating={track.userRating ?? 0} dimInactive tabindex={-1} showTitle onrating={(value) => setRating(track, value)} > <Icon name="star" /> </MediaAction> </div> {/if} <div id="playback"> <div class="main"> <div class="transport"> <button type="button" id="playback-prev" class="pressable" tabindex="-1" onclick={prev} title={t("common.prev")} > <Icon name="previous" /> </button> <button type="button" id="playback-toggle" class="pressable" tabindex="-1" onclick={toggle} title={player.paused ? t("common.play") : t("common.pause")} > <Icon name={player.paused ? "play" : "pause"} /> </button> <button type="button" id="playback-next" class="pressable" tabindex="-1" onclick={next} title={t("common.next")} > <Icon name="next" /> </button> {#if settings.controlLoop || player.loop} <button type="button" id="playback-loop" class="pressable" tabindex="-1" aria-pressed={player.loop} onclick={() => (player.loop = !player.loop)} title={t("common.loop")} > <Icon name="repeat" /> </button> {/if} {#if settings.controlMiniplayer || ui.miniplayer} <button type="button" id="playback-miniplayer" class="pressable" tabindex="-1" aria-pressed={ui.miniplayer} onclick={() => (ui.miniplayer = !ui.miniplayer)} title={t("common.miniplayer")} > <Icon name="image" /> </button> {/if} {#if settings.controlFavorite && !settings.controlRating && player.track && !trackIsRadio} {@render favoriteButton(player.track)} {/if} </div>
{#if settings.controlTime} <span class="time"> {#if trackIsRadio} LIVE {:else} {formatTime(player.time)}/{formatTime(player.duration)} {/if} </span> {/if}
{#if settings.controlVolume} <div id="playback-volume" title={t("common.volume")}> <button type="button" id="playback-mute" class="pressable" tabindex="-1" onclick={toggleMute} title={t("common.volume")} > <Icon name={player.vol === 0 ? "mute" : "volume"} /> </button> <ProgressBar value={player.vol} variant={settings.volumeBarStyle} oninput={(event) => (player.vol = event.currentTarget.valueAsNumber)} /> </div> {/if} </div>
<div id="playback-progress" class:waveform={isWaveform}> {#if isWaveform && WaveformOverlay.current} {@const Overlay = WaveformOverlay.current} <Overlay /> {/if} <ProgressBar max={player.duration || 1} step={0.1} value={trackIsRadio ? 1 : player.time} disabled={!player.track || trackIsRadio} loading={!trackIsRadio && (player.loading || player.waiting)} variant={settings.progressBarStyle} onkeydown={startScrub} onpointerdown={startScrub} oninput={(event) => { if (scrubTrackId !== player.track?.id) return; seek(event.currentTarget.valueAsNumber); }} /> </div> </div>
{#if !ui.miniplayer} <div id="actions"> <div class="group"> <button type="button" tabindex="-1" class="library-toggle pressable" aria-expanded={ui.showLibraryMobile} onclick={() => (ui.showLibraryMobile = !ui.showLibraryMobile)} title={t("common.library")} > <Icon name="library" /> </button> {#if queue.tracks.length > 0} <span class="stats" >{queue.tracks.length}<span class="unit" >{" "}{t( queue.tracks.length === 1 ? "common.song" : "common.songs", )}</span >{totalDurationLabel} {#if queue.sel.length > 0} {" "}({queue.sel.length}{selectedDurationLabel}) {/if}</span > {/if} </div> <div class="group"> <button type="button" class="pressable pressable--gentle" onclick={(event) => { menu = { x: event.clientX, y: event.clientY, }; sortExpanded = false; }} disabled={!hasVisibleQueueTracks()} title={t("action.sort_queue")} tabindex="-1" > <Icon name="sort" /> <span class="label">{t("common.sort")}</span> </button> <button type="button" class="pressable pressable--gentle" onclick={() => clear()} disabled={!hasVisibleQueueTracks()} title={t("action.clear_queue")} tabindex="-1" > <Icon name="close" /> <span class="label">{t("common.clear")}</span> </button> <button type="button" class="pressable pressable--gentle" onclick={() => (ui.showSettings = true)} title={t("common.settings")} tabindex="-1" > <Icon name="settings" /> <span class="label">{t("common.settings")}</span> </button> </div> </div> {/if}</section>
{#if menu} <ContextMenu x={menu.x} y={menu.y} onclose={() => { menu = null; sortExpanded = false; }} items={getSortItems(undefined, sortExpanded, () => (sortExpanded = true))} />{/if}
<style>#controls { container-type: inline-size;}#controls > :first-child { border-block-start: var(--border);}#controls > :not(:first-child) { border-block-start: var(--border-subtle);}#playback { display: flex; gap: 0.5rem; padding: 0.5rem 1rem; align-items: center;
.main { display: contents; } .transport, #playback-volume { display: flex; align-items: center; gap: 0.5rem; } .time { font-variant-numeric: tabular-nums; } #playback-volume { order: 2; }
@container (width <= 36rem) { display: grid; grid-template-columns: minmax(0, 1fr);
.main { display: flex; align-items: center; gap: 0.5rem; } .transport { margin-inline-end: auto; } }
@container (width <= 26rem) { .time { display: none; } }
@container (width <= 20rem) { #playback-volume { display: none; } }}#playback-progress > :global(.progress-bar) { flex: 1;}#controls:not(.time-tabular) .time { font-family: var(--font-family-monospace);}#playback-progress { display: flex; align-items: center; gap: 0.5rem; order: 1; min-inline-size: 0; flex: 1;}#playback-progress.waveform { display: grid; grid-template-columns: minmax(0, 1fr); block-size: 0.75rem; align-self: center;}:global(html.motion) #playback-progress > :global(.waveform-overlay) { transform-origin: bottom; transition: transform var(--button-press-duration) ease-out;}:global(html.motion) #playback-progress:active > :global(.waveform-overlay) { transform: scaleY(var(--progress-press-scale, var(--button-press-scale-y)));}#playback-volume > :global(.progress-bar) { --progress-press-scale: 0.875; inline-size: 5rem;}#playback-prev { --button-transform-origin: 75%;}#playback-next { --button-transform-origin: 25%;}:global(:dir(rtl)) #playback-prev { --button-transform-origin: 25%;}:global(:dir(rtl)) #playback-next { --button-transform-origin: 75%;}
button { display: flex; align-items: center; justify-content: center;}#actions { display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 1rem; gap: 0.5rem;
> .group { display: flex; align-items: center; gap: 1rem; }
.stats { @container (width <= 32rem) { .unit { display: none; } } @container (width <= 20rem) { display: none; } }
button { gap: 0.25rem;
@container (width <= 32rem) { --button-press-scale-x: 1.25; --button-press-scale-y: 0.75; --button-hover-duration: 400ms; --button-press-duration: 75ms;
.label { display: none; } } }}
.library-toggle { display: none;
@media (width <= 32rem) { display: flex; }}button:is([aria-pressed="false"], [aria-expanded="false"]) { opacity: 0.5;}
#ratings { display: flex; gap: 0.5rem; padding: 0.5rem 1rem;}@container (width <= 36rem) { :global(html.miniplayer) #controls.time-disabled.volume-disabled { #ratings, #playback .main { justify-content: center; } #playback .transport { margin-inline-end: 0; } }}@container (width <= 26rem) { :global(html.miniplayer) #controls.volume-disabled { #ratings, #playback .main { justify-content: center; } #playback .transport { margin-inline-end: 0; } }}@container (width <= 20rem) { :global(html.miniplayer) #controls { #ratings, #playback .main { justify-content: center; } #playback .transport { margin-inline-end: 0; } }}</style>