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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266<script lang="ts">import { auth, login, removeLibrarySource } from "../auth.svelte.js";import { anonymousGetExtensions, anonymousPing, asArray,} from "../client.svelte.js";import { deploymentConfig } from "../deploymentConfig.js";import { t } from "../languages/translate.svelte.js";import { orderedSources, setPrimarySource, sourceDisplayName, sources,} from "../sources.svelte.js";
let serverUrl = $state(deploymentConfig.server);let serverName = $state("");let authMode = $state<"auto" | "apikey">("auto");let user = $state(deploymentConfig.username);let password = $state(deploymentConfig.password);let pingStatus = $state<"none" | "loading" | "ok" | "fail">("none");let isApiKeySupported = $state(false);
const librarySources = $derived(orderedSources());
$effect(() => { if (!serverUrl.trim()) { pingStatus = "none"; isApiKeySupported = false; authMode = "auto"; return; }
pingStatus = "loading"; isApiKeySupported = false; const requestServer = serverUrl; const timer = setTimeout(async () => { try { const data = await anonymousPing(requestServer); if (serverUrl !== requestServer) return; const isSubsonic = data && (data.status === "ok" || data.status === "failed"); pingStatus = isSubsonic ? "ok" : "fail";
if (isSubsonic && data.openSubsonic) { try { const extRes = await anonymousGetExtensions(requestServer); if (serverUrl !== requestServer) return; const list = asArray( extRes.openSubsonicExtensions?.extension || extRes.openSubsonicExtensions, ); isApiKeySupported = list.some( (e: { name?: string }) => e?.name?.toLowerCase() === "apikeyauthentication", ); } catch (err) { console.warn("failed to fetch extensions anonymously:", err); } } } catch { if (serverUrl === requestServer) pingStatus = "fail"; }
if (!isApiKeySupported && authMode === "apikey") authMode = "auto"; }, 500);
return () => clearTimeout(timer);});
async function addLibrary(event: SubmitEvent) { event.preventDefault(); const added = await login( serverUrl, authMode === "apikey" ? "" : user, password, authMode, serverName, ); if (added) { serverUrl = ""; serverName = ""; user = ""; password = ""; authMode = "auto"; }}
async function logoutLibrary(sourceId: string, name: string) { if (!confirm(t("message.remove_server_confirm", { name }))) return; if (!(await removeLibrarySource(sourceId))) alert(t("message.failed_delete"));}</script>
<section class="libraries-settings"> {#if librarySources.length} <div class="library-list"> {#each librarySources as source (source.id)} <div class="library-entry"> <div> <strong>{sourceDisplayName(source, librarySources)}</strong> <small>{source.credentials.server}</small> </div> <div class="entry-actions"> <button type="button" class="native" disabled={source.id === sources.primaryId} onclick={() => setPrimarySource(source.id)} > {t("common.primary")} </button> <button type="button" class="native" onclick={() => logoutLibrary( source.id, sourceDisplayName(source, librarySources), )} > {t("common.logout")} </button> </div> </div> {/each} </div> {/if}
<form onsubmit={addLibrary}> <label> <span>{t("settings.server_name")}</span> <input name="serverName" bind:value={serverName} placeholder={t("settings.server_name_placeholder")} autocomplete="organization" > </label>
{#if !deploymentConfig.serverLock} <label> <span>{t("settings.server_url")}</span> <input name="serverUrl" bind:value={serverUrl} placeholder={t("settings.server_url")} required autocomplete="url" > </label> {/if}
{#if pingStatus === "loading"} <div class="text-footnote">...</div> {:else if pingStatus === "ok"} <div class="ok text-footnote">{t("auth.server_found")}</div> {:else if pingStatus === "fail"} <div class="fail text-footnote"> {t("auth.server_not_found")} - <a target="_blank" href="https://tangled.org/devins.page/tinysub#i-cant-log-in" >{t("auth.login_notice")}</a > </div> {/if}
{#if isApiKeySupported} <select aria-label={authMode === "apikey" ? t("auth.apikey") : t("auth.username_password")} bind:value={authMode} > <option value="auto">{t("auth.username_password")}</option> <option value="apikey">{t("auth.apikey")}</option> </select> {/if}
{#if authMode !== "apikey"} <label> <span>{t("auth.username_placeholder")}</span> <input name="username" bind:value={user} placeholder={t("auth.username_placeholder")} required autocomplete="username" > </label> {/if} <label> <span >{authMode === "apikey" ? t("auth.apikey") : t("auth.password_placeholder")}</span > <input name="password" bind:value={password} type="password" placeholder={authMode === "apikey" ? t("auth.apikey") : t("auth.password_placeholder")} required autocomplete={authMode === "apikey" ? "off" : "current-password"} > </label>
<button class="native" type="submit" disabled={auth.busy}> {auth.busy ? t("auth.logging_in") : t("auth.login")} </button> {#if auth.err} <div class="fail text-footnote">{auth.err}</div> {/if} </form></section>
<style>.libraries-settings { display: grid; gap: 1rem;}.library-list,form { display: grid; gap: 0.5rem;}.library-entry { display: flex; align-items: center; gap: 1rem; padding-block: 0.5rem; border-block-end: var(--border-subtle);
& > :first-child { display: grid; flex: 1 1 auto; min-inline-size: 0;
& > :is(strong, small) { min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } }}.entry-actions { display: flex; flex-shrink: 0; gap: 0.25rem;}label { display: grid; gap: 0.25rem;}.ok { color: green;}.fail { color: red;}</style>