fix: previews follow the app volume; volume bar fill always accent

The preview backend never received a volume — clips played at the crate
default (0.7) regardless of the slider, even muted. The session now sets
the preview volume from the app store (mute → 0) before the first load and
mirrors it live while the modal is open; the debug overlay's PREVIEW row
exposes the backend's resting volume as proof.

The player-bar volume (and fallback seek) fill is now always the accent
color — matching the waveform scrubber's played region — stepping up to
the hover shade on hover instead of white-until-hover.

Also syncs package-lock to the soulstream name + alpha version.
This commit is contained in:
KeelrLeek committed 2026-07-23 16:25:42 -05:00
1 parent 793f78370e
commit 691fdcdc9a
10 files changed
+63 -10

No files matched your search

+4 -4
View File
@@ -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"
+1 -1
View File
@@ -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",
+5
View File
@@ -79,6 +79,8 @@ pub struct DebugStats {
pub normalization_enabled: bool,
pub target_lufs: f64,
pub loudness: Option<crate::loudness::CurrentTrack>,
/// The preview voices' resting volume (mirrors the app volume; 0 = muted).
pub preview_volume: Option<f32>,
}
#[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::<crate::preview::PreviewState>()
.map(|s| crate::preview::target_volume(&s)),
}
}
+10 -3
View File
@@ -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
+1
View File
@@ -124,6 +124,7 @@
<b>PREVIEW</b>
{previewUi.snapshot?.phase ?? "—"} · gen {previewUi.snapshot?.requestGeneration ?? "—"} ·
idx {previewUi.snapshot?.activeManifestIndex ?? "—"}
{#if stats.previewVolume != null}· vol {stats.previewVolume.toFixed(2)}{/if}
</div>
{/if}
</div>
+4 -2
View File
@@ -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 */
+9
View File
@@ -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 ?? []);
+8
View File
@@ -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),
};
+7
View File
@@ -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;
+14
View File
@@ -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 () => {