From 8049d024573472a2661e37d9f5fcda6e01991971 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 11 Aug 2026 13:30:01 -0400 Subject: [PATCH] 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). --- src/hooks/useAudio.ts | 21 +++++++- src/stores/app.ts | 9 +++- src/types/settings.ts | 2 + src/utils/app-persistence.ts | 1 + tests/volume-persistence.test.ts | 85 ++++++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 tests/volume-persistence.test.ts diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts index a3f21f8..7ffdcaf 100644 --- a/src/hooks/useAudio.ts +++ b/src/hooks/useAudio.ts @@ -74,7 +74,7 @@ let pollCount = 0; // Counts poll ticks for throttling progress saves const [isPlaying, setIsPlaying] = createSignal(false); const [position, setPosition] = createSignal(0); const [duration, setDuration] = createSignal(0); -const [volume, setVolume] = createSignal(0.7); +const [volume, setVolume] = createSignal(1); const [speed, setSpeed] = createSignal(1); const [backendName, setBackendName] = createSignal("none"); const [error, setError] = createSignal(null); @@ -440,6 +440,10 @@ async function doSetVolume(vol: number): Promise { } } 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 { @@ -555,7 +559,8 @@ export function useAudio(): AudioControls { // Initialize backend on first use 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) { const appStore = useAppStore(); const storeSpeed = appStore.state().settings.playbackSpeed; @@ -563,6 +568,18 @@ export function useAudio(): AudioControls { 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). restoreLastSession().catch(() => {}); } diff --git a/src/stores/app.ts b/src/stores/app.ts index 389722e..630c130 100644 --- a/src/stores/app.ts +++ b/src/stores/app.ts @@ -28,6 +28,7 @@ const defaultSettings: AppSettings = { theme: "system", fontSize: 14, playbackSpeed: 1, + volume: 1, downloadPath: "", transparentBackground: false, showSelectionMarker: false, @@ -55,12 +56,14 @@ function createAppStore() { // Start with defaults; async load will update once ready const [state, setState] = createSignal(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 loaded = await loadAppStateFromFile(); setState(loaded); }; - init(); + const appInit = init(); const saveState = (next: AppState) => { saveAppStateToFile(next); @@ -119,6 +122,8 @@ function createAppStore() { return { state, + /** Resolves once persisted settings are loaded from disk. */ + whenReady: () => appInit, updateSettings, updatePreferences, updateCustomTheme, diff --git a/src/types/settings.ts b/src/types/settings.ts index 07f4c74..16393b6 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -78,6 +78,8 @@ export type AppSettings = { theme: ThemeName; fontSize: number; playbackSpeed: number; + /** Playback volume 0–1 (default: 1 = 100%). */ + volume: number; downloadPath: string; /** Render the app background transparent (let the terminal's own bg show). */ transparentBackground: boolean; diff --git a/src/utils/app-persistence.ts b/src/utils/app-persistence.ts index 8aa9cd3..e528eb6 100644 --- a/src/utils/app-persistence.ts +++ b/src/utils/app-persistence.ts @@ -32,6 +32,7 @@ const defaultSettings: AppSettings = { theme: "system", fontSize: 14, playbackSpeed: 1, + volume: 1, downloadPath: "", transparentBackground: false, showSelectionMarker: false, diff --git a/tests/volume-persistence.test.ts b/tests/volume-persistence.test.ts new file mode 100644 index 0000000..8109530 --- /dev/null +++ b/tests/volume-persistence.test.ts @@ -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 { + 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 0–1 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); +});