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:
@@ -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(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export type AppSettings = {
|
|||||||
theme: ThemeName;
|
theme: ThemeName;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
playbackSpeed: number;
|
playbackSpeed: number;
|
||||||
|
/** Playback volume 0–1 (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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
85
tests/volume-persistence.test.ts
Normal file
85
tests/volume-persistence.test.ts
Normal 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 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user