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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436<script lang="ts">import { isRadio, type Song } from "./client.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 } from "./player.svelte.js";import { clear, getSortItems, next, prev, queue, setRating, toggleStar,} from "./queue.svelte.js";import { settings } from "./settings.svelte.js";import { ui } from "./ui.svelte.js";
let scrubTrackId: string | undefined;const startScrub = () => (scrubTrackId = player.track?.id);
const trackIsRadio = $derived(!!player.track && isRadio(player.track));let menu = $state<{ x: number; y: number; items: ReturnType<typeof getSortItems>;} | null>(null);</script>
<section id="controls" aria-label={t("common.controls")} class:time-disabled={!settings.controlTime} class:volume-disabled={!settings.controlVolume}> {#snippet favoriteButton(track: Song)} <MediaAction kind="favorite" starred={!!track.starred} dimInactive tabindex={-1} showTitle onfavorite={() => toggleStar(track)} > <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="playback-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" class:off={!player.loop} 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" class:off={!ui.miniplayer} 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" class="volume" title={t("common.volume")}> <Icon name={player.vol === 0 ? "mute" : "volume"} /> <ProgressBar value={player.vol} variant={settings.volumeBarStyle} oninput={(event) => (player.vol = event.currentTarget.valueAsNumber)} /> </div> {/if} </div>
<div id="playback-progress" class="progress"> <ProgressBar max={player.duration || 1} step={0.1} value={trackIsRadio ? 1 : player.time} disabled={!player.track || trackIsRadio} loading={!trackIsRadio && player.loading} 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> <button type="button" tabindex="-1" class="library-toggle pressable" class:off={!ui.showLibraryMobile} aria-expanded={ui.showLibraryMobile} onclick={() => (ui.showLibraryMobile = !ui.showLibraryMobile)} title={t("common.library")} > <Icon name="library" /> </button> <span class="stats"> {#if queue.tracks.length > 0} {queue.tracks.length} {t( queue.tracks.length === 1 ? "common.song" : "common.songs", )} {#if queue.sel.length > 0} {" "}({queue.sel.length} {t("common.selected")}) {/if} {/if} </span> </div> <div> <button type="button" class="pressable pressable--gentle" onclick={(event) => { menu = { x: event.clientX, y: event.clientY, items: getSortItems(), }; }} disabled={!queue.tracks.length} title={t("action.sort_queue")} tabindex="-1" > <Icon name="sort" /> <span>{t("common.sort")}</span> </button> <button type="button" class="pressable pressable--gentle" onclick={clear} disabled={!queue.tracks.length} title={t("action.clear_queue")} tabindex="-1" > <Icon name="close" /> <span>{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>{t("common.settings")}</span> </button> </div> </div> {/if}</section>
{#if menu} <ContextMenu x={menu.x} y={menu.y} onclose={() => (menu = null)} items={menu.items} />{/if}
<style>#controls { container-type: inline-size;}#playback { display: flex; border-block-start: var(--border); gap: 0.5rem; padding: 0.5rem 1rem;
.playback-main { display: contents; } .transport, .volume, .progress, .time { display: flex; align-items: center; gap: 0.5rem; } .time { font-family: var(--font-family-monospace); } .progress { order: 1; min-inline-size: 0; flex: 1; } .volume { order: 2; }
@container (width <= 36rem) { display: grid; grid-template-columns: minmax(0, 1fr);
.playback-main { display: flex; align-items: center; gap: 0.5rem; } .transport { margin-inline-end: auto; } }
@container (width <= 26rem) { .time { display: none; } }
@container (width <= 20rem) { .volume { display: none; } }}.progress > :global(.progress-bar) { flex: 1;}.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; border-block-start: var(--border-subtle);
> * { display: flex; align-items: center; gap: 1rem; }
.stats { @container (width <= 20rem) { @media (width <= 32rem) { display: none; } } }
button { gap: 0.25rem;
&:disabled { opacity: 0.5; }
@container (width <= 32rem) { --button-press-scale-x: 1.25; --button-press-scale-y: 0.75; --button-hover-duration: 400ms; --button-press-duration: 75ms;
span { display: none; } } }}
.library-toggle { display: none;
@media (width <= 32rem) { display: flex; }}.off { opacity: 0.5;}
#ratings { display: flex; border-block-start: var(--border); gap: 0.5rem; padding: 0.5rem 1rem;}#ratings + #playback { border-block-start: var(--border-subtle);}
@container (width <= 36rem) { :global(html.miniplayer) #controls.time-disabled.volume-disabled { #ratings, #playback .playback-main { justify-content: center; } #playback .transport { margin-inline-end: 0; } }}@container (width <= 26rem) { :global(html.miniplayer) #controls.volume-disabled { #ratings, #playback .playback-main { justify-content: center; } #playback .transport { margin-inline-end: 0; } }}@container (width <= 20rem) { :global(html.miniplayer) #controls { #ratings, #playback .playback-main { justify-content: center; } #playback .transport { margin-inline-end: 0; } }}</style>