feat(player): persist volume across sessions, default to 100%

Store the playback volume in app settings (config.json) whenever it
changes and re-apply the previous session's level at boot, instead of
always starting at the old 70% fallback.

- AppSettings gains volume (default 1 = 100%); both default-settings
  copies and the volume signal default are raised from 0.7 to 1.
- doSetVolume persists via the app store (mirrors playbackSpeed).
- The boot sync awaits the app store's async config load (new
  whenReady()) so a persisted level is applied even when settings load
  finishes after useAudio mounts.
- tests/volume-persistence.test.ts: default, clamp, and cross-session
  reuse (fresh module instance simulates the next launch).
This commit is contained in:
2026-08-11 13:30:01 -04:00
parent 1b55b7117c
commit 8049d02457
5 changed files with 114 additions and 4 deletions

View File

@@ -74,7 +74,7 @@ let pollCount = 0; // Counts poll ticks for throttling progress saves
const [isPlaying, setIsPlaying] = createSignal(false); const [isPlaying, setIsPlaying] = createSignal(false);
const [position, setPosition] = createSignal(0); const [position, setPosition] = createSignal(0);
const [duration, setDuration] = createSignal(0); const [duration, setDuration] = createSignal(0);
const [volume, setVolume] = createSignal(0.7); const [volume, setVolume] = createSignal(1);
const [speed, setSpeed] = createSignal(1); const [speed, setSpeed] = createSignal(1);
const [backendName, setBackendName] = createSignal<BackendName>("none"); const [backendName, setBackendName] = createSignal<BackendName>("none");
const [error, setError] = createSignal<string | null>(null); const [error, setError] = createSignal<string | null>(null);
@@ -440,6 +440,10 @@ async function doSetVolume(vol: number): Promise<void> {
} }
} }
setVolume(clamped); setVolume(clamped);
// Sync back to app store (persisted to config.json for the next launch).
const appStore = useAppStore();
appStore.updateSettings({ volume: clamped });
} }
async function doSetSpeed(spd: number): Promise<void> { async function doSetSpeed(spd: number): Promise<void> {
@@ -555,7 +559,8 @@ export function useAudio(): AudioControls {
// Initialize backend on first use // Initialize backend on first use
ensureBackend(); ensureBackend();
// Sync initial speed from app store // Sync initial speed/volume from app store (reuse the previous session's
// playback levels; defaults are 1x and 100%).
if (refCount === 0) { if (refCount === 0) {
const appStore = useAppStore(); const appStore = useAppStore();
const storeSpeed = appStore.state().settings.playbackSpeed; const storeSpeed = appStore.state().settings.playbackSpeed;
@@ -563,6 +568,18 @@ export function useAudio(): AudioControls {
setSpeed(storeSpeed); setSpeed(storeSpeed);
} }
// Volume re-syncs once settings finish loading (async config read)
// so a level persisted last session is applied at boot.
appStore
.whenReady()
.then(() => {
const storeVolume = appStore.state().settings.volume;
if (storeVolume !== undefined && storeVolume !== volume()) {
setVolume(storeVolume);
}
})
.catch(() => {});
// Restore the last player session once at boot (loaded, not playing). // Restore the last player session once at boot (loaded, not playing).
restoreLastSession().catch(() => {}); restoreLastSession().catch(() => {});
} }

View File

@@ -28,6 +28,7 @@ const defaultSettings: AppSettings = {
theme: "system", theme: "system",
fontSize: 14, fontSize: 14,
playbackSpeed: 1, playbackSpeed: 1,
volume: 1,
downloadPath: "", downloadPath: "",
transparentBackground: false, transparentBackground: false,
showSelectionMarker: false, showSelectionMarker: false,
@@ -55,12 +56,14 @@ function createAppStore() {
// Start with defaults; async load will update once ready // Start with defaults; async load will update once ready
const [state, setState] = createSignal<AppState>(defaultState); const [state, setState] = createSignal<AppState>(defaultState);
// Fire-and-forget async initialisation // Fire-and-forget async initialisation; the promise is exposed via
// whenReady() so boot-time consumers (audio-level restore) can await
// the config read before reading settings.
const init = async () => { const init = async () => {
const loaded = await loadAppStateFromFile(); const loaded = await loadAppStateFromFile();
setState(loaded); setState(loaded);
}; };
init(); const appInit = init();
const saveState = (next: AppState) => { const saveState = (next: AppState) => {
saveAppStateToFile(next); saveAppStateToFile(next);
@@ -119,6 +122,8 @@ function createAppStore() {
return { return {
state, state,
/** Resolves once persisted settings are loaded from disk. */
whenReady: () => appInit,
updateSettings, updateSettings,
updatePreferences, updatePreferences,
updateCustomTheme, updateCustomTheme,

View File

@@ -78,6 +78,8 @@ export type AppSettings = {
theme: ThemeName; theme: ThemeName;
fontSize: number; fontSize: number;
playbackSpeed: number; playbackSpeed: number;
/** Playback volume 01 (default: 1 = 100%). */
volume: number;
downloadPath: string; downloadPath: string;
/** Render the app background transparent (let the terminal's own bg show). */ /** Render the app background transparent (let the terminal's own bg show). */
transparentBackground: boolean; transparentBackground: boolean;

View File

@@ -32,6 +32,7 @@ const defaultSettings: AppSettings = {
theme: "system", theme: "system",
fontSize: 14, fontSize: 14,
playbackSpeed: 1, playbackSpeed: 1,
volume: 1,
downloadPath: "", downloadPath: "",
transparentBackground: false, transparentBackground: false,
showSelectionMarker: false, showSelectionMarker: false,

View File

@@ -0,0 +1,85 @@
/**
* volume-persistence.test.ts — "store and reuse the previous session's
* audio level" feature.
*
* useAudio's volume starts at 100% (default, before any user change);
* setVolume() persists the new level to app settings (config.json); and
* at boot the volume signal re-syncs from the persisted settings — so the
* next session resumes at the previous level.
*
* Same worker-leak defenses as restore-session.test.ts: other test files
* mock.module("../src/hooks/useAudio") and bun reuses workers, so the real
* module is imported via a query-suffixed specifier (distinct module
* identity, loads from disk). A fresh module instance simulates the next
* launch: its refCount starts at 0, so its boot sync re-reads settings.
* The boot sync awaits the app store's async config load (whenReady), so
* tests await it too and flush microtasks — no wall-clock sleeps.
*/
import { test, expect } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// ── Sandbox BEFORE any app module evaluates ───────────────────────────────
const CONFIG = mkdtempSync(join(tmpdir(), "podtui-volume-"));
process.env.XDG_CONFIG_HOME = CONFIG;
process.env.XDG_DATA_HOME = mkdtempSync(join(tmpdir(), "podtui-volume-data-"));
process.env.PODTUI_AUDIO_BACKEND = "none";
// ── Real modules ──────────────────────────────────────────────────────────
// @ts-expect-error — bun-only query suffix: distinct module identity that
// loads the real file instead of a leaked mock.module from another test file.
const { useAudio } = await import("../src/hooks/useAudio?restore-test");
const { useAppStore } = await import("../src/stores/app");
/** Flush the boot sync's promise chain (whenReady.then(...)) — microtasks
* only, no timers. */
async function flushMicrotasks(): Promise<void> {
for (let i = 0; i < 5; i++) await Promise.resolve();
}
test("volume defaults to 100% and is persisted and reused across sessions", async () => {
const appStore = useAppStore();
await appStore.whenReady(); // empty sandbox config → defaults
// Boot 1: no persisted volume — the default is 100% (not the old 70%).
const audio = useAudio();
await flushMicrotasks();
expect(audio.volume()).toBe(1);
expect(appStore.state().settings.volume).toBe(1);
// User change: signal updates and the level lands in app settings.
await audio.setVolume(0.35);
expect(audio.volume()).toBe(0.35);
expect(appStore.state().settings.volume).toBe(0.35);
// Boot 2 (fresh module instance — refCount starts at 0, so the boot
// sync re-reads settings): the previous session's level is restored.
// @ts-expect-error — same bun-only query-suffix mechanism as above.
const { useAudio: useAudioNext } = await import("../src/hooks/useAudio?restore-test-vol");
const nextAudio = useAudioNext();
await flushMicrotasks();
expect(nextAudio.volume()).toBe(0.35);
});
test("setVolume clamps to the 01 range before persisting", async () => {
const audio = useAudio();
await audio.setVolume(1.7);
expect(audio.volume()).toBe(1);
expect(useAppStore().state().settings.volume).toBe(1);
await audio.setVolume(-0.3);
expect(audio.volume()).toBe(0);
expect(useAppStore().state().settings.volume).toBe(0);
});
test("a persisted volume wins over the default at boot", async () => {
// Persist a non-default level, then simulate a fresh launch that has no
// prior signal state (new module instance).
useAppStore().updateSettings({ volume: 0.6 });
// @ts-expect-error — same bun-only query-suffix mechanism as above.
const { useAudio: useAudioNext } = await import("../src/hooks/useAudio?restore-test-vol2");
const nextAudio = useAudioNext();
await flushMicrotasks();
expect(nextAudio.volume()).toBe(0.6);
});