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:
1 parent
793f78370e
commit
691fdcdc9a
10 files changed
+63
-10
No files matched your search
Generated
+4
-4
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "soulseekplus",
|
"name": "soulstream",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0-alpha.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "soulseekplus",
|
"name": "soulstream",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0-alpha.1",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2"
|
"@tauri-apps/api": "^2"
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "soulstream",
|
"name": "soulstream",
|
||||||
"version": "0.1.0-alpha.1",
|
"version": "0.1.0-alpha.1",
|
||||||
"type": "module",
|
"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": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ pub struct DebugStats {
|
|||||||
pub normalization_enabled: bool,
|
pub normalization_enabled: bool,
|
||||||
pub target_lufs: f64,
|
pub target_lufs: f64,
|
||||||
pub loudness: Option<crate::loudness::CurrentTrack>,
|
pub loudness: Option<crate::loudness::CurrentTrack>,
|
||||||
|
/// The preview voices' resting volume (mirrors the app volume; 0 = muted).
|
||||||
|
pub preview_volume: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -163,6 +165,9 @@ pub fn debug_stats(app: tauri::AppHandle, state: State<'_, DiagState>) -> DebugS
|
|||||||
normalization_enabled,
|
normalization_enabled,
|
||||||
target_lufs,
|
target_lufs,
|
||||||
loudness,
|
loudness,
|
||||||
|
preview_volume: app
|
||||||
|
.try_state::<crate::preview::PreviewState>()
|
||||||
|
.map(|s| crate::preview::target_volume(&s)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
/// smooth — while each step is one cheap volume command to the sink.
|
||||||
const FADE_TICK: Duration = Duration::from_millis(15);
|
const FADE_TICK: Duration = Duration::from_millis(15);
|
||||||
|
|
||||||
/// The preview voices' resting volume. The player crate's own default; the
|
/// The preview voices' resting volume before the renderer speaks. Since
|
||||||
/// renderer never sets a preview volume today, so fades ramp to this unless
|
/// 2026-07-23 the renderer mirrors the APP volume (mute → 0) via the
|
||||||
/// a `volume` control changes the target.
|
/// `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;
|
const DEFAULT_TARGET_VOL: f32 = 0.7;
|
||||||
|
|
||||||
/// Lease-lifetime constants, mirroring `src/lib/preview/lease.js` — the two
|
/// 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
|
/// How long a crossfade waits for the incoming voice to become audible
|
||||||
/// before ramping anyway. Local-file decode start is tens of ms; hitting
|
/// 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
|
/// this means the clip is broken (its error event fails the item) and the
|
||||||
|
|||||||
@@ -124,6 +124,7 @@
|
|||||||
<b>PREVIEW</b>
|
<b>PREVIEW</b>
|
||||||
{previewUi.snapshot?.phase ?? "—"} · gen {previewUi.snapshot?.requestGeneration ?? "—"} ·
|
{previewUi.snapshot?.phase ?? "—"} · gen {previewUi.snapshot?.requestGeneration ?? "—"} ·
|
||||||
idx {previewUi.snapshot?.activeManifestIndex ?? "—"}
|
idx {previewUi.snapshot?.activeManifestIndex ?? "—"}
|
||||||
|
{#if stats.previewVolume != null}· vol {stats.previewVolume.toFixed(2)}{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -706,15 +706,17 @@
|
|||||||
border-radius: 2px;
|
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 {
|
.fill {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--text-primary);
|
background: var(--accent);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track:hover .fill {
|
.track:hover .fill {
|
||||||
background: var(--accent);
|
background: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* waveform scrubber */
|
/* waveform scrubber */
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
previewCommands,
|
previewCommands,
|
||||||
closePreview,
|
closePreview,
|
||||||
} from "../lib/preview/previewSession.svelte.js";
|
} from "../lib/preview/previewSession.svelte.js";
|
||||||
|
import { player } from "../lib/state.svelte.js";
|
||||||
import {
|
import {
|
||||||
playableManifestIndices,
|
playableManifestIndices,
|
||||||
playableCursor,
|
playableCursor,
|
||||||
@@ -25,6 +26,14 @@
|
|||||||
} from "../lib/preview/session.js";
|
} from "../lib/preview/session.js";
|
||||||
import { accentFromImage } from "../lib/color.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 st = $derived(previewUi.snapshot);
|
||||||
const manifest = $derived(st?.manifest ?? null);
|
const manifest = $derived(st?.manifest ?? null);
|
||||||
const items = $derived(manifest?.items ?? []);
|
const items = $derived(manifest?.items ?? []);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ let service = null;
|
|||||||
let unsubscribe = null;
|
let unsubscribe = null;
|
||||||
let focusParts = null; // { coordinator, gateway }
|
let focusParts = null; // { coordinator, gateway }
|
||||||
let demoTicker = null;
|
let demoTicker = null;
|
||||||
|
let readPlayerRef = null; // injected with the bridge; app volume source
|
||||||
|
|
||||||
// ------------------------------------------------------------- gateway ----
|
// ------------------------------------------------------------- gateway ----
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ export function notePlaybackGeneration() {
|
|||||||
|
|
||||||
/** One-time wiring from state.svelte.js. */
|
/** One-time wiring from state.svelte.js. */
|
||||||
export function initPreviewBridge({ readPlayer, resumeMain }) {
|
export function initPreviewBridge({ readPlayer, resumeMain }) {
|
||||||
|
readPlayerRef = readPlayer;
|
||||||
focusParts = createProductionFocus({
|
focusParts = createProductionFocus({
|
||||||
readPlayer,
|
readPlayer,
|
||||||
readPlaybackGeneration: () => playbackGeneration,
|
readPlaybackGeneration: () => playbackGeneration,
|
||||||
@@ -81,6 +83,11 @@ export async function openPreview(manifest, { playFull } = {}) {
|
|||||||
focus: focusParts,
|
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;
|
previewUi.open = true;
|
||||||
await service.open({ sessionId: crypto.randomUUID(), manifest });
|
await service.open({ sessionId: crypto.randomUUID(), manifest });
|
||||||
}
|
}
|
||||||
@@ -176,4 +183,5 @@ export const previewCommands = {
|
|||||||
seek: (ms) => service?.seek(ms),
|
seek: (ms) => service?.seek(ms),
|
||||||
setMuted: (v) => service?.setMuted(v),
|
setMuted: (v) => service?.setMuted(v),
|
||||||
playFullTrack: (starter) => service?.playFullTrack(starter),
|
playFullTrack: (starter) => service?.playFullTrack(starter),
|
||||||
|
setVolume: (v) => service?.setVolume(v),
|
||||||
};
|
};
|
||||||
@@ -348,6 +348,13 @@ export function createPreviewService({
|
|||||||
if (value && state.phase === PHASE.PLAYING) void transport.pause();
|
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() {
|
async togglePlay() {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
const sid = state.sessionId;
|
const sid = state.sessionId;
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ function makeTransport() {
|
|||||||
async seek(ms) {
|
async seek(ms) {
|
||||||
log.push(["seek", ms]);
|
log.push(["seek", ms]);
|
||||||
},
|
},
|
||||||
|
async setVolume(v) {
|
||||||
|
log.push(["setVolume", v]);
|
||||||
|
},
|
||||||
async stop() {
|
async stop() {
|
||||||
ctx = null;
|
ctx = null;
|
||||||
log.push(["stop"]);
|
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);
|
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 ----
|
// ------------------------------------------------------- resume memory ----
|
||||||
|
|
||||||
test("resume: stepping back to a track picks up where it left off", async () => {
|
test("resume: stepping back to a track picks up where it left off", async () => {
|
||||||
|
|||||||
Reference in new issue
Block a user