first run, updates, installer: a setup tour stands in front of a fresh install, releases arrive through an in-app updater with stable and nightly channels behind a read-only token, and the branded installer builds again

This commit is contained in:
KeelrLeek committed 2026-09-09 16:16:19 -05:00
1 parent 07810f7543
commit cd2391198e
19 files changed
+2010 -15

No files matched your search

+586
View File
@@ -0,0 +1,586 @@
<script>
// The first-run setup: a tour that stands in front of the app on a fresh
// install (config.setup_done unset) and asks for the things the app
// cannot guess — where music lives, the Soulseek account, whether the
// community peer runs, how the window is drawn. Every answer is applied
// the moment its step is left, so a skip halfway loses nothing, and
// Settings → Account runs the tour again. Solid surfaces on purpose:
// no backdrop-filter anywhere (dead over the frost, Library.svelte's
// note) — the scrim is a plain veil, the panel the elevated surface.
import { tick } from "svelte";
import { fade, fly, scale } from "svelte/transition";
import { cubicOut, quintOut } from "svelte/easing";
import Icon from "./Icon.svelte";
import * as api from "../lib/api.js";
import { app, connect } from "../lib/state.svelte.js";
import { applyGlass } from "../lib/glass.js";
import { firstRun, finishFirstRun } from "../lib/firstRun.svelte.js";
import {
community,
refreshFreenet,
setFreenetEnabled,
refreshPersona,
createPersona,
publishProfile,
personaKey,
} from "../lib/community.svelte.js";
const STEPS = [
{ id: "welcome", label: "Welcome", icon: "sparkle" },
{ id: "library", label: "Your library", icon: "library" },
{ id: "soulseek", label: "Soulseek", icon: "users" },
{ id: "community", label: "Community", icon: "globe" },
{ id: "look", label: "Look & sound", icon: "eye" },
{ id: "ready", label: "Ready", icon: "check" },
];
// Album folder formats offered as one-click presets; the field beside
// them takes anything Settings would.
const PRESETS = [
["{artist}\\{album}", "Artist › Album"],
["{artist}\\{year} - {album}", "Artist › Year - Album"],
["{artist} - {album}", "Artist - Album"],
];
let step = $state(0);
let dir = $state(1);
let settings = $state(null);
let libraryDir = $state("");
let layout = $state("");
let username = $state("");
let password = $state("");
let remember = $state(true);
let freenetOn = $state(false);
let displayName = $state("");
let glass = $state(false);
let loudness = $state(true);
let busy = $state(false);
let error = $state("");
let closing = $state(false);
let panelEl = $state(null);
const cur = $derived(STEPS[step]);
const online = $derived(app.connection === "online");
const reduced = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
const dur = (ms) => (reduced() ? 0 : ms);
$effect(() => {
if (!firstRun.open) return;
step = 0;
dir = 1;
error = "";
closing = false;
password = "";
void load();
});
async function load() {
try {
settings = await api.getSettings();
libraryDir = settings.download_dir ?? "";
layout = settings.library_layout ?? "";
username = settings.username ?? "";
glass = !!settings.glass_mode;
loudness = settings.volume_normalization !== false;
} catch (e) {
error = String(e);
}
try {
await refreshFreenet();
freenetOn = !!community.freenet?.enabled;
} catch {
freenetOn = false;
}
await tick();
panelEl?.querySelector(".primary")?.focus();
}
async function commit(patch) {
busy = true;
error = "";
try {
await api.setSettings(patch);
return true;
} catch (e) {
error = String(e);
return false;
} finally {
busy = false;
}
}
async function pickDir() {
const dir = await api.pickFolder().catch(() => null);
if (dir) libraryDir = dir;
}
async function signIn() {
if (!username.trim() || !password) return;
await connect(username.trim(), password, remember);
}
async function setGlass(v) {
glass = v;
// Applies live, like Settings: the acrylic and the CSS thinning land
// together, and the scrim lets the change show around the panel.
if (await commit({ glassMode: v })) applyGlass(v);
}
async function setLoudness(v) {
loudness = v;
await commit({ volumeNormalization: v });
}
async function next() {
if (busy || closing) return;
if (cur.id === "library") {
const patch = {};
if (libraryDir && libraryDir !== settings?.download_dir) patch.downloadDir = libraryDir;
const lay = layout.trim();
if (lay && lay !== settings?.library_layout) patch.libraryLayout = lay;
if (Object.keys(patch).length) {
if (!(await commit(patch))) return;
if (settings) {
if (patch.downloadDir) settings.download_dir = patch.downloadDir;
if (patch.libraryLayout) settings.library_layout = patch.libraryLayout;
}
}
} else if (cur.id === "community") {
if (freenetOn !== !!community.freenet?.enabled) {
busy = true;
error = "";
try {
await setFreenetEnabled(freenetOn);
} catch (e) {
error = String(e);
} finally {
busy = false;
}
if (error) return;
}
}
if (step < STEPS.length - 1) {
dir = 1;
step += 1;
await tick();
panelEl?.querySelector(".primary")?.focus();
}
}
function back() {
if (step === 0 || busy || closing) return;
dir = -1;
step -= 1;
}
async function finish() {
if (closing) return;
closing = true;
const name = displayName.trim();
const wantsIdentity = freenetOn && name;
await finishFirstRun();
if (wantsIdentity) {
// Off the tour, best effort: the peer may still be coming up.
// Either half can be redone from Community → You.
void (async () => {
try {
await refreshPersona();
if (!personaKey()) await createPersona();
await publishProfile(name, "");
} catch {
// Community → You shows the state and the buttons
}
})();
}
}
function onKeydown(e) {
if (!firstRun.open || closing) return;
if (e.key === "Tab") return trapTab(e);
if (e.key !== "Enter" || e.shiftKey) return;
const t = e.target;
if (t instanceof HTMLButtonElement || t instanceof HTMLTextAreaElement) return;
if (t instanceof HTMLInputElement && t.form) return; // the sign-in form submits itself
e.preventDefault();
if (cur.id === "ready") finish();
else next();
}
function trapTab(e) {
if (!panelEl) return;
const focusables = [...panelEl.querySelectorAll('button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])')];
if (!focusables.length) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
} else if (!panelEl.contains(document.activeElement)) {
e.preventDefault();
first.focus();
}
}
</script>
<svelte:window onkeydown={onKeydown} />
{#if firstRun.open}
<div class="scrim" transition:fade={{ duration: dur(260) }} role="presentation">
<div
class="panel"
bind:this={panelEl}
role="dialog"
aria-modal="true"
aria-labelledby="fr-title"
in:scale={{ start: 0.96, duration: dur(460), easing: quintOut }}
out:scale={{ start: 1.03, duration: dur(320), easing: cubicOut }}
>
<aside class="rail" aria-label="Setup steps">
<div class="brand">
<img src="/logo.png" alt="" draggable="false" />
<span>SoulStream</span>
</div>
<ol>
{#each STEPS as s, i (s.id)}
<li class:on={i === step} class:done={i < step} aria-current={i === step ? "step" : undefined}>
<span class="dot">
{#if i < step}<Icon name="check" size={11} />{:else}<Icon name={s.icon} size={12} />{/if}
</span>
<span class="lab">{s.label}</span>
</li>
{/each}
</ol>
<div class="railfoot">Everything here lives in Settings too.</div>
</aside>
<div class="stage">
<div class="steps">
{#key step}
<section
class="step"
in:fly={{ x: 36 * dir, duration: dur(380), easing: quintOut, delay: dur(60) }}
out:fly={{ x: -36 * dir, duration: dur(200), easing: cubicOut }}
>
{#if cur.id === "welcome"}
<div class="welcome">
<div class="halo" in:scale={{ start: 0.6, duration: dur(760), easing: quintOut }}>
<img class="mark" src="/logo.png" alt="" draggable="false" />
</div>
<h1 id="fr-title" in:fly={{ y: 14, duration: dur(520), delay: dur(220), easing: quintOut }}>Welcome to SoulStream</h1>
<p class="lede" in:fly={{ y: 14, duration: dur(520), delay: dur(340), easing: quintOut }}>
Your library on your own disk, music from people on Soulseek, and a community
that runs on a peer you own. Four short questions and the app is yours.
</p>
<ul class="pills" in:fly={{ y: 10, duration: dur(520), delay: dur(460), easing: quintOut }}>
{#each STEPS.slice(1, 5) as s (s.id)}
<li><Icon name={s.icon} size={13} /> {s.label}</li>
{/each}
</ul>
</div>
{:else if cur.id === "library"}
<h1 id="fr-title">Where does your music live?</h1>
<p class="lede">
Downloads land in this folder, it is always shared on Soulseek, and albums are
filed into it by the format below. Files already in it are never moved.
</p>
<div class="card">
<div class="row">
<div class="label">
<div class="name">Library folder</div>
<div class="path">{libraryDir || "Not chosen yet"}</div>
</div>
<div class="ctl"><button class="ax-white" onclick={pickDir} disabled={busy}>Choose folder…</button></div>
</div>
<div class="row">
<div class="label">
<div class="name">Album folder format</div>
<div class="hint">Use {"{artist}"}, {"{album}"}, {"{date}"} or {"{year}"}; "\" starts a subfolder.</div>
<div class="presets">
{#each PRESETS as [v, l] (v)}
<button class="preset" class:on={layout.trim() === v} onclick={() => (layout = v)}>{l}</button>
{/each}
</div>
</div>
<div class="ctl"><input class="mono" bind:value={layout} spellcheck="false" autocomplete="off" aria-label="Album folder format" /></div>
</div>
</div>
{:else if cur.id === "soulseek"}
<h1 id="fr-title">Your Soulseek account</h1>
<p class="lede">
Any username and password works — a new account is created on first sign-in,
so pick something unique. The password goes to the Windows Credential Manager,
never a plain file.
</p>
{#if online}
<div class="card">
<div class="row">
<div class="label">
<div class="name">Signed in <span class="tag ok">online</span></div>
<div class="hint">As <b>{app.username}</b>. Your library folder is being shared. You can switch accounts any time in Settings → Account.</div>
</div>
</div>
</div>
{:else}
<form class="card form" onsubmit={(e) => { e.preventDefault(); signIn(); }}>
<label class="field">
<span>Username</span>
<input bind:value={username} spellcheck="false" autocomplete="off" />
</label>
<label class="field">
<span>Password</span>
<input type="password" bind:value={password} autocomplete="off" />
</label>
<label class="check"><input type="checkbox" bind:checked={remember} /> Remember me on this computer</label>
{#if app.loginError}<div class="err">{app.loginError}</div>{/if}
<div class="formfoot">
<button class="ax-white" type="submit" disabled={app.connection === "connecting" || !username.trim() || !password}>
{app.connection === "connecting" ? "Connecting…" : "Sign in"}
</button>
<span class="hint">Or press Later and sign in when you first play something.</span>
</div>
</form>
{/if}
{:else if cur.id === "community"}
<h1 id="fr-title">A community on your own peer</h1>
<p class="lede">
The social side — an identity, a profile, shared playlists, friends and Spaces —
runs on Freenet, a peer on this machine. Off by default, and nothing is ever
published until you press a button that says so.
</p>
<div class="card">
<div class="row">
<div class="label">
<div class="name">Run the community peer <span class="tag">experimental</span></div>
<div class="hint">Uses some network, disk and CPU in the background. Turning it off later is instant and touches nothing else in the app.</div>
</div>
<div class="ctl">
<span class="tval">{freenetOn ? "on" : "off"}</span>
<button class="toggle" class:on={freenetOn} role="switch" aria-checked={freenetOn} aria-label="Run the community peer" onclick={() => (freenetOn = !freenetOn)} disabled={busy}><i></i></button>
</div>
</div>
{#if freenetOn}
<div class="row" in:fly={{ y: -6, duration: dur(220) }}>
<div class="label">
<div class="name">Display name</div>
<div class="hint">What others see on your profile. Optional — the identity is created either way, and the profile can be written from Community → You.</div>
</div>
<div class="ctl"><input bind:value={displayName} placeholder="Your name" maxlength="48" spellcheck="false" autocomplete="off" aria-label="Display name" /></div>
</div>
{/if}
</div>
{:else if cur.id === "look"}
<h1 id="fr-title">How the window is drawn</h1>
<p class="lede">Both apply as you flip them, so look at the window behind this card.</p>
<div class="card">
<div class="row">
<div class="label">
<div class="name">Dark glass</div>
<div class="hint">Whatever is behind the window glows through the app's dark surfaces with a live blur (Windows acrylic). Menus and dialogs stay solid.</div>
</div>
<div class="ctl">
<span class="tval">{glass ? "dark glass" : "solid"}</span>
<button class="toggle" class:on={glass} role="switch" aria-checked={glass} aria-label="Dark glass" onclick={() => setGlass(!glass)} disabled={busy}><i></i></button>
</div>
</div>
<div class="row">
<div class="label">
<div class="name">Loudness normalization</div>
<div class="hint">Every track plays at roughly the same level (−14 LUFS). Off plays each file as recorded.</div>
</div>
<div class="ctl">
<span class="tval">{loudness ? "on" : "off"}</span>
<button class="toggle" class:on={loudness} role="switch" aria-checked={loudness} aria-label="Loudness normalization" onclick={() => setLoudness(!loudness)} disabled={busy}><i></i></button>
</div>
</div>
</div>
{:else}
<h1 id="fr-title">You're set</h1>
<p class="lede">Here is what you chose. Every line of it lives in Settings.</p>
<ul class="summary">
{#each [
["folder-open", "Library", libraryDir || "not chosen"],
["users", "Soulseek", online ? `signed in as ${app.username}` : "not signed in yet"],
["globe", "Community peer", freenetOn ? (displayName.trim() ? `on · ${displayName.trim()}` : "on") : "off"],
["eye", "Window", glass ? "dark glass" : "solid"],
["volume", "Loudness", loudness ? "normalized" : "as recorded"],
] as [icon, what, value], i (what)}
<li in:fly={{ y: 10, duration: dur(360), delay: dur(120 + i * 70), easing: quintOut }}>
<span class="ic"><Icon name={icon} size={14} /></span>
<span class="what">{what}</span>
<b class:mono={what === "Library"}>{value}</b>
</li>
{/each}
</ul>
{/if}
</section>
{/key}
</div>
<footer class="foot">
{#if step > 0}
<button class="ax-ghost" onclick={back} disabled={busy || closing}>Back</button>
{/if}
<span class="spacer"></span>
{#if error}<span class="err">{error}</span>{/if}
{#if cur.id === "welcome"}
<button class="lnk" onclick={finish} disabled={closing}>Skip setup</button>
<button class="ax-fill primary" onclick={next} disabled={closing}>Let's set it up <Icon name="chevron-right" size={14} /></button>
{:else if cur.id === "ready"}
<button class="ax-fill primary big" onclick={finish} disabled={closing}>Open SoulStream</button>
{:else}
{#if cur.id === "soulseek" && !online}
<button class="lnk" onclick={next} disabled={busy}>Later</button>
{/if}
<button class="ax-fill primary" onclick={next} disabled={busy || closing}>{busy ? "Saving…" : "Next"} <Icon name="chevron-right" size={14} /></button>
{/if}
</footer>
</div>
</div>
</div>
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
z-index: 110;
display: grid;
place-items: center;
/* A plain veil — never a backdrop-filter (dead over the frost). */
background: rgba(0, 0, 0, 0.72);
--surf: var(--bg-elevated);
--surf-hi: var(--bg-highlight);
--hair: rgba(255, 255, 255, 0.07);
--ok: #3ddc84;
}
.panel {
width: min(780px, 94vw);
height: min(560px, 92vh);
display: grid;
grid-template-columns: 212px 1fr;
background: var(--bg-elevated);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 18px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6);
overflow: hidden;
color: var(--text-primary);
}
/* ── the rail ─────────────────────────────────────────────────── */
.rail {
display: flex;
flex-direction: column;
background: var(--bg-base);
border-right: 1px solid var(--hair);
padding: 22px 16px 18px;
min-width: 0;
}
.brand { display: flex; align-items: center; gap: 10px; padding: 0 6px 22px; font-size: 15px; font-weight: 900; letter-spacing: 0.01em; }
.brand img { width: 26px; height: 26px; border-radius: 7px; }
.rail ol { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.rail li { display: flex; align-items: center; gap: 10px; padding: 8px 8px; border-radius: 10px; color: var(--text-muted); font-size: 12.5px; font-weight: 700; transition: color 0.2s, background 0.2s; }
.rail li.on { color: var(--text-primary); background: rgba(255, 255, 255, 0.06); }
.rail li.done { color: var(--text-secondary); }
.dot { width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; background: var(--surf-hi); color: var(--text-secondary); flex: none; transition: background 0.2s, color 0.2s; }
.rail li.on .dot { background: var(--accent); color: #000; }
.rail li.done .dot { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); }
.railfoot { margin-top: auto; font-size: 11px; color: var(--text-muted); line-height: 1.5; padding: 0 6px; }
/* ── the stage ────────────────────────────────────────────────── */
.stage { display: grid; grid-template-rows: 1fr auto; min-width: 0; }
.steps { position: relative; display: grid; overflow: hidden; }
/* In and out share the cell so a step leaving never pushes the next. */
.steps > .step { grid-area: 1 / 1; padding: 30px 32px 8px; overflow: auto; min-height: 0; }
h1 { font-size: 22px; font-weight: 900; letter-spacing: -0.01em; margin: 0 0 8px; line-height: 1.15; }
.lede { font-size: 13px; color: var(--text-secondary); line-height: 1.55; margin: 0 0 18px; max-width: 58ch; }
.welcome { display: flex; flex-direction: column; align-items: center; text-align: center; padding-top: 14px; }
.welcome h1 { font-size: 26px; margin-top: 18px; }
.welcome .lede { max-width: 46ch; }
.halo {
position: relative;
width: 108px;
height: 108px;
display: grid;
place-items: center;
}
.halo::before {
content: "";
position: absolute;
inset: -18px;
border-radius: 50%;
background: radial-gradient(circle, color-mix(in srgb, var(--accent) 55%, transparent) 0, color-mix(in srgb, var(--accent) 18%, transparent) 38%, transparent 70%);
animation: breathe 3.8s ease-in-out infinite;
}
.mark { position: relative; width: 84px; height: 84px; border-radius: 22px; box-shadow: 0 18px 40px rgba(0, 0, 0, 0.5); }
@keyframes breathe {
0%, 100% { transform: scale(1); opacity: 0.85; }
50% { transform: scale(1.1); opacity: 1; }
}
.pills { list-style: none; margin: 4px 0 0; padding: 0; display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
.pills li { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 99px; background: var(--surf-hi); font-size: 12px; font-weight: 800; color: var(--text-secondary); }
/* Settings' own card vocabulary, so the tour and the page read as one. */
.card { background: var(--surf); border: 1px solid var(--hair); border-radius: 12px; overflow: hidden; }
.row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 16px; border-top: 1px solid var(--hair); }
.card > .row:first-child { border-top: none; }
.label { min-width: 0; flex: 1; }
.name { font-size: 13.5px; font-weight: 700; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.hint { font-size: 12px; color: var(--text-secondary); margin-top: 3px; line-height: 1.45; max-width: 60ch; }
.hint b { color: var(--text-primary); font-weight: 700; }
.path { font-family: "Cascadia Code", Consolas, monospace; font-size: 11.5px; color: var(--text-secondary); word-break: break-all; margin-top: 3px; }
.ctl { flex: none; display: flex; align-items: center; gap: 8px; }
.tag { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 7px; border-radius: 99px; background: var(--accent-soft); color: var(--accent-hover); display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }
.tag.ok { background: color-mix(in srgb, var(--ok) 14%, transparent); color: #5ee89a; }
.tval { font-size: 12px; font-weight: 800; color: var(--text-secondary); min-width: 62px; text-align: right; }
.toggle { width: 38px; height: 21px; border-radius: 99px; background: var(--surf-hi); position: relative; flex: none; transition: 0.18s; }
.toggle i { position: absolute; top: 3px; left: 3px; width: 15px; height: 15px; border-radius: 50%; background: var(--text-muted); transition: 0.18s; }
.toggle.on { background: var(--accent); }
.toggle.on i { left: 20px; background: #000; }
.toggle:disabled { opacity: 0.45; cursor: default; }
.presets { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
.preset { padding: 4px 10px; border-radius: 99px; font-size: 11.5px; font-weight: 800; background: var(--surf-hi); color: var(--text-secondary); }
.preset:hover { color: #fff; }
.preset.on { background: var(--accent); color: #000; }
input { height: 32px; padding: 0 10px; border-radius: 8px; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); color: var(--text-primary); font: inherit; font-size: 13px; min-width: 0; }
input:focus { outline: none; border-color: var(--accent); }
input.mono { font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; width: 220px; }
.form { display: flex; flex-direction: column; gap: 12px; padding: 16px; }
.field { display: flex; flex-direction: column; gap: 5px; font-size: 12px; font-weight: 700; color: var(--text-secondary); }
.field input { width: 100%; max-width: 360px; }
.check { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--text-secondary); }
.check input { height: auto; accent-color: var(--accent); }
.formfoot { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.err { font-size: 12px; color: #ff8d98; }
.summary { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.summary li { display: grid; grid-template-columns: 28px 130px 1fr; align-items: center; gap: 8px; padding: 10px 12px; border-radius: 10px; background: var(--surf); border: 1px solid var(--hair); font-size: 13px; }
.summary .ic { display: grid; place-items: center; color: var(--accent-hover); }
.summary .what { color: var(--text-secondary); font-weight: 700; }
.summary b { font-weight: 800; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.summary b.mono { font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; font-weight: 600; }
/* ── the footer ───────────────────────────────────────────────── */
.foot { display: flex; align-items: center; gap: 12px; padding: 14px 24px 18px 32px; border-top: 1px solid var(--hair); }
.spacer { flex: 1; }
.lnk { color: var(--text-muted); font-weight: 700; font-size: 12px; text-decoration: underline; text-underline-offset: 2px; padding: 0 6px; }
.lnk:hover:not(:disabled) { color: var(--text-primary); }
.ax-ghost { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 14px; border-radius: 99px; font-size: 12px; font-weight: 800; border: 1px solid rgba(255, 255, 255, 0.28); transition: 0.18s; white-space: nowrap; }
.ax-ghost:hover:not(:disabled) { border-color: #fff; background: rgba(255, 255, 255, 0.08); }
.ax-ghost:disabled { opacity: 0.45; cursor: default; }
.ax-fill { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 18px; border-radius: 99px; font-size: 12.5px; font-weight: 800; background: var(--accent); color: #000; white-space: nowrap; transition: background 0.18s, transform 0.15s; }
.ax-fill:hover:not(:disabled) { background: var(--accent-hover); }
.ax-fill:disabled { opacity: 0.55; cursor: default; }
.ax-fill.big { height: 38px; padding: 0 22px; font-size: 13.5px; }
.ax-white { display: inline-flex; align-items: center; height: 32px; padding: 0 16px; border-radius: 99px; font-size: 12.5px; font-weight: 800; background: #fff; color: #000; white-space: nowrap; transition: transform 0.15s; }
.ax-white:hover:not(:disabled) { transform: scale(1.03); }
.ax-white:disabled { opacity: 0.55; cursor: default; }
@media (prefers-reduced-motion: reduce) {
.halo::before { animation: none; }
}
</style>
+130
View File
@@ -0,0 +1,130 @@
<script>
// A release was found by the automatic check: the same pill and slot as
// the other toasts, but it STAYS until answered — Install here, or Later
// (the Settings card keeps the offer). Installing on its own was never on
// the table; this is the button the updater's note talks about.
import Icon from "./Icon.svelte";
import { updater, installUpdate, dismissUpdate } from "../lib/updater.svelte.js";
const available = $derived(updater.status?.available ?? null);
const installing = $derived(!!updater.status?.installing);
const shown = $derived(!!available && !updater.dismissed);
const pct = $derived.by(() => {
const p = updater.progress;
if (!p || !p.total) return null;
return Math.min(100, Math.round((p.received / p.total) * 100));
});
</script>
{#if shown}
<div class="layer" aria-live="polite">
<div class="toast" role="status">
<span class="ic"><Icon name="download" size={14} /></span>
{#if installing}
<span class="text">Installing SoulStream {available.version}{#if pct != null} · {pct}%{:else}…{/if}</span>
<span class="bar" aria-hidden="true"><i style="width: {pct ?? 8}%"></i></span>
{:else}
<span class="text">SoulStream <b>{available.version}</b> is ready</span>
<button class="go" onclick={installUpdate}>Install and restart</button>
<button class="later" onclick={dismissUpdate} title="Later" aria-label="Later"><Icon name="x" size={13} /></button>
{/if}
</div>
</div>
{/if}
<style>
/* The shared toast slot — FollowToast's geometry verbatim. */
.layer {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--player-height, 62px) + 8px + 26px);
display: flex;
justify-content: center;
z-index: 80;
pointer-events: none;
}
.toast {
pointer-events: auto;
display: inline-flex;
align-items: center;
gap: 10px;
max-width: min(70vw, 560px);
padding: 7px 8px 7px 16px;
border-radius: 99px;
background: rgba(22, 23, 28, 0.96);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.55);
font-size: 12.5px;
font-weight: 700;
color: #fff;
animation: toast-in 340ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(16px) scale(0.95);
}
to {
opacity: 1;
transform: none;
}
}
.ic {
display: inline-flex;
color: var(--accent);
}
.text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
b {
font-weight: 800;
}
.go {
height: 28px;
padding: 0 12px;
border-radius: 99px;
background: var(--accent);
color: #000;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.go:hover {
background: var(--accent-hover);
}
.later {
width: 28px;
height: 28px;
border-radius: 99px;
display: grid;
place-items: center;
color: var(--text-secondary);
}
.later:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.bar {
width: 120px;
height: 4px;
border-radius: 99px;
background: rgba(255, 255, 255, 0.12);
overflow: hidden;
margin-right: 8px;
}
.bar i {
display: block;
height: 100%;
background: var(--accent);
border-radius: 99px;
transition: width 0.2s;
}
@media (prefers-reduced-motion: reduce) {
.toast {
animation-duration: 1ms;
}
}
</style>