diff --git a/package-lock.json b/package-lock.json index f529f09..2d879d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "soulseekplus", - "version": "0.1.0", + "name": "soulstream", + "version": "0.1.0-alpha.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "soulseekplus", - "version": "0.1.0", + "name": "soulstream", + "version": "0.1.0-alpha.1", "license": "ISC", "dependencies": { "@tauri-apps/api": "^2" diff --git a/package.json b/package.json index c8be4b1..148f61f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "soulstream", "version": "0.1.0-alpha.1", "type": "module", - "description": "SoulStream \u2014 music player, downloader and streamer for the Soulseek network", + "description": "SoulStream — music player, downloader and streamer for the Soulseek network", "scripts": { "dev": "vite", "build": "vite build", diff --git a/src-tauri/src/diagnostics.rs b/src-tauri/src/diagnostics.rs index a5ba7f3..eb3c3ed 100644 --- a/src-tauri/src/diagnostics.rs +++ b/src-tauri/src/diagnostics.rs @@ -79,6 +79,8 @@ pub struct DebugStats { pub normalization_enabled: bool, pub target_lufs: f64, pub loudness: Option, + /// The preview voices' resting volume (mirrors the app volume; 0 = muted). + pub preview_volume: Option, } #[tauri::command] @@ -163,6 +165,9 @@ pub fn debug_stats(app: tauri::AppHandle, state: State<'_, DiagState>) -> DebugS normalization_enabled, target_lufs, loudness, + preview_volume: app + .try_state::() + .map(|s| crate::preview::target_volume(&s)), } } diff --git a/src-tauri/src/preview.rs b/src-tauri/src/preview.rs index fe0de12..1b5d789 100644 --- a/src-tauri/src/preview.rs +++ b/src-tauri/src/preview.rs @@ -34,9 +34,10 @@ const PREVIEW_POSITION_INTERVAL: Duration = Duration::from_millis(100); /// smooth — while each step is one cheap volume command to the sink. const FADE_TICK: Duration = Duration::from_millis(15); -/// The preview voices' resting volume. The player crate's own default; the -/// renderer never sets a preview volume today, so fades ramp to this unless -/// a `volume` control changes the target. +/// The preview voices' resting volume before the renderer speaks. Since +/// 2026-07-23 the renderer mirrors the APP volume (mute → 0) via the +/// `volume` control at session open and live while the modal is up, so +/// this default only covers the instant before that first set arrives. const DEFAULT_TARGET_VOL: f32 = 0.7; /// Lease-lifetime constants, mirroring `src/lib/preview/lease.js` — the two @@ -111,6 +112,12 @@ impl PreviewState { } } +/// The preview voices' current resting volume — diagnostics readout, so the +/// debug overlay can prove the renderer's app-volume mirror actually lands. +pub fn target_volume(state: &PreviewState) -> f32 { + state.target() +} + /// How long a crossfade waits for the incoming voice to become audible /// before ramping anyway. Local-file decode start is tens of ms; hitting /// this means the clip is broken (its error event fails the item) and the diff --git a/src/components/DebugOverlay.svelte b/src/components/DebugOverlay.svelte index 7b3056e..98cb037 100644 --- a/src/components/DebugOverlay.svelte +++ b/src/components/DebugOverlay.svelte @@ -124,6 +124,7 @@ PREVIEW {previewUi.snapshot?.phase ?? "—"} · gen {previewUi.snapshot?.requestGeneration ?? "—"} · idx {previewUi.snapshot?.activeManifestIndex ?? "—"} + {#if stats.previewVolume != null}· vol {stats.previewVolume.toFixed(2)}{/if} {/if} diff --git a/src/components/PlayerBar.svelte b/src/components/PlayerBar.svelte index 11c6b8c..a5f653a 100644 --- a/src/components/PlayerBar.svelte +++ b/src/components/PlayerBar.svelte @@ -706,15 +706,17 @@ border-radius: 2px; } + /* Always the accent — matching the waveform scrubber's played region + (user decision 2026-07-23; the old white-until-hover read as off). */ .fill { position: relative; height: 100%; - background: var(--text-primary); + background: var(--accent); border-radius: 2px; } .track:hover .fill { - background: var(--accent); + background: var(--accent-hover); } /* waveform scrubber */ diff --git a/src/components/PreviewOverlay.svelte b/src/components/PreviewOverlay.svelte index 739075f..1038800 100644 --- a/src/components/PreviewOverlay.svelte +++ b/src/components/PreviewOverlay.svelte @@ -18,6 +18,7 @@ previewCommands, closePreview, } from "../lib/preview/previewSession.svelte.js"; + import { player } from "../lib/state.svelte.js"; import { playableManifestIndices, playableCursor, @@ -25,6 +26,14 @@ } from "../lib/preview/session.js"; import { accentFromImage } from "../lib/color.js"; + // Preview audio follows the APP volume — mute means silent previews + // too. Mirrored live while the modal is open (the session also sets it + // once at open, before the first load). + $effect(() => { + if (!previewUi.open) return; + previewCommands.setVolume(player.muted ? 0 : player.volume); + }); + const st = $derived(previewUi.snapshot); const manifest = $derived(st?.manifest ?? null); const items = $derived(manifest?.items ?? []); diff --git a/src/lib/preview/previewSession.svelte.js b/src/lib/preview/previewSession.svelte.js index 3ca6ba2..725d694 100644 --- a/src/lib/preview/previewSession.svelte.js +++ b/src/lib/preview/previewSession.svelte.js @@ -30,6 +30,7 @@ let service = null; let unsubscribe = null; let focusParts = null; // { coordinator, gateway } let demoTicker = null; +let readPlayerRef = null; // injected with the bridge; app volume source // ------------------------------------------------------------- gateway ---- @@ -50,6 +51,7 @@ export function notePlaybackGeneration() { /** One-time wiring from state.svelte.js. */ export function initPreviewBridge({ readPlayer, resumeMain }) { + readPlayerRef = readPlayer; focusParts = createProductionFocus({ readPlayer, readPlaybackGeneration: () => playbackGeneration, @@ -81,6 +83,11 @@ export async function openPreview(manifest, { playFull } = {}) { focus: focusParts, }) ); + // Preview volume follows the APP volume (mute = 0) — set it BEFORE the + // first load so the opening clip can't blast at the backend default. + // The overlay keeps it mirrored live while the modal is open. + const p = readPlayerRef?.(); + if (p) service.setVolume(p.muted ? 0 : (p.volume ?? 0.7)); previewUi.open = true; await service.open({ sessionId: crypto.randomUUID(), manifest }); } @@ -176,4 +183,5 @@ export const previewCommands = { seek: (ms) => service?.seek(ms), setMuted: (v) => service?.setMuted(v), playFullTrack: (starter) => service?.playFullTrack(starter), + setVolume: (v) => service?.setVolume(v), }; diff --git a/src/lib/preview/service.js b/src/lib/preview/service.js index 0c5b519..581a73e 100644 --- a/src/lib/preview/service.js +++ b/src/lib/preview/service.js @@ -348,6 +348,13 @@ export function createPreviewService({ if (value && state.phase === PHASE.PLAYING) void transport.pause(); }, + /** Mirror the APP's volume (and mute, as 0) onto the preview + * transport — previews must never play louder than the player bar + * says. No-op for transports without a volume control (fixture). */ + setVolume(v) { + void transport.setVolume?.(v); + }, + async togglePlay() { if (!state) return; const sid = state.sessionId; diff --git a/tests/preview/service.test.mjs b/tests/preview/service.test.mjs index b441d09..8c583be 100644 --- a/tests/preview/service.test.mjs +++ b/tests/preview/service.test.mjs @@ -72,6 +72,9 @@ function makeTransport() { async seek(ms) { log.push(["seek", ms]); }, + async setVolume(v) { + log.push(["setVolume", v]); + }, async stop() { ctx = null; log.push(["stop"]); @@ -259,6 +262,17 @@ test("auto-play: the LAST playable track never advances early", async () => { assert.equal(service.getState().phase, PHASE.ENDED); }); +test("setVolume forwards the app volume to the transport", async () => { + const { service, transport, manifest } = makeService(); + await service.open({ sessionId: SID, manifest }); + service.setVolume(0.35); + service.setVolume(0); // app mute → silent previews + assert.deepEqual( + transport.log.filter((e) => e[0] === "setVolume"), + [["setVolume", 0.35], ["setVolume", 0]] + ); +}); + // ------------------------------------------------------- resume memory ---- test("resume: stepping back to a track picks up where it left off", async () => {