From 8b7b38276e15f8886dffc94b153ebf9c3de40df1 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 11 Aug 2026 14:15:48 -0400 Subject: [PATCH] feat(player): toggleable waveform visualizer in settings, default on --- src/pages/Player/PlayerPage.tsx | 9 ++- src/pages/Settings/SettingsPage.tsx | 2 +- src/pages/Settings/VisualizerSettings.tsx | 9 +++ src/stores/app.ts | 1 + src/stores/visualizer.ts | 6 +- src/types/settings.ts | 2 + src/utils/app-persistence.ts | 14 +++- tests/visualizer-store.test.ts | 25 ++++++ tests/visualizer-toggle.test.ts | 94 +++++++++++++++++++++++ 9 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 tests/visualizer-toggle.test.ts diff --git a/src/pages/Player/PlayerPage.tsx b/src/pages/Player/PlayerPage.tsx index 7eca4aa..63a3866 100644 --- a/src/pages/Player/PlayerPage.tsx +++ b/src/pages/Player/PlayerPage.tsx @@ -16,6 +16,7 @@ import { ProgressBar } from "./ProgressBar"; import { RealtimeWaveform } from "./RealtimeWaveform"; import { useAudio } from "@/hooks/useAudio"; import { useVisualizer } from "@/stores/visualizer"; +import { useAppStore } from "@/stores/app"; import { useTheme } from "@/context/ThemeContext"; import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext"; import { PaneRow } from "@/components/PaneRow"; @@ -28,7 +29,11 @@ export function PlayerPage() { const { theme } = useTheme(); const nav = useNavigation(); const viz = useVisualizer(); + const app = useAppStore(); const muted = () => theme.muted || theme.text; + // Settings master switch: off hides the waveform entirely (the store + // also stops the decode+FFT pipeline, see stores/visualizer.ts). + const vizEnabled = () => app.state().settings.visualizer.enabled; // The page is mounted exactly while the Player tab is in focus (Shell // renders only the active tab), so mount ⇔ focused. Report it to the @@ -90,7 +95,9 @@ export function PlayerPage() { - + + + )} diff --git a/src/pages/Settings/SettingsPage.tsx b/src/pages/Settings/SettingsPage.tsx index 7fd243d..0d10c12 100644 --- a/src/pages/Settings/SettingsPage.tsx +++ b/src/pages/Settings/SettingsPage.tsx @@ -64,7 +64,7 @@ const SECTIONS: SettingsSectionDef[] = [ { id: 3, label: "Visualizer", - description: "Audio visualizer: bars, sensitivity, cutoffs.", + description: "Audio visualizer: on/off, bars, sensitivity, cutoffs.", icon: NF_ICONS.visualizer, }, { diff --git a/src/pages/Settings/VisualizerSettings.tsx b/src/pages/Settings/VisualizerSettings.tsx index 05edcc9..78ea5ae 100644 --- a/src/pages/Settings/VisualizerSettings.tsx +++ b/src/pages/Settings/VisualizerSettings.tsx @@ -11,6 +11,15 @@ export function useVisualizerItems(): SettingItem[] { const viz = () => app.state().settings.visualizer; return [ + { + id: "enabled", + label: "Waveform", + kind: "toggle", + display: () => (viz().enabled ? "On" : "Off"), + help: () => + `Realtime waveform visualizer in the player.\nType: toggle\nDefault: on\nCurrent: ${viz().enabled ? "on" : "off"}\nSpace/Enter to toggle.`, + toggle: () => app.updateVisualizer({ enabled: !viz().enabled }), + }, { id: "bars", label: "Bars", diff --git a/src/stores/app.ts b/src/stores/app.ts index 630c130..468b7e8 100644 --- a/src/stores/app.ts +++ b/src/stores/app.ts @@ -17,6 +17,7 @@ import { } from "../utils/app-persistence"; const defaultVisualizerSettings: VisualizerSettings = { + enabled: true, bars: 64, sensitivity: 1, noiseReduction: 0.77, diff --git a/src/stores/visualizer.ts b/src/stores/visualizer.ts index 0d2af64..9637117 100644 --- a/src/stores/visualizer.ts +++ b/src/stores/visualizer.ts @@ -250,9 +250,10 @@ function createVisualizerStore(): VisualizerStore { audioPlaybackSignals.speed, barCount, focused, + () => useAppStore().state().settings.visualizer.enabled, ], - ([playing, url, speed]) => { - if (!playing || !url) { + ([playing, url, speed, , , enabled]) => { + if (!playing || !url || !enabled) { stopVisualization(); return; } @@ -289,6 +290,7 @@ function createVisualizerStore(): VisualizerStore { if ( audioPlaybackSignals.isPlaying() && audioPlaybackSignals.currentEpisode()?.audioUrl && + useAppStore().state().settings.visualizer.enabled && frameTimer === null ) { startVisualization( diff --git a/src/types/settings.ts b/src/types/settings.ts index 16393b6..d557bcf 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -62,6 +62,8 @@ export type DesktopTheme = { }; export type VisualizerSettings = { + /** Master on/off switch for the player's realtime waveform (default: on). */ + enabled: boolean; /** Number of frequency bars (8–128, default: 64) */ bars: number; /** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */ diff --git a/src/utils/app-persistence.ts b/src/utils/app-persistence.ts index e528eb6..d51deb1 100644 --- a/src/utils/app-persistence.ts +++ b/src/utils/app-persistence.ts @@ -21,6 +21,7 @@ import { DEFAULT_THEME } from "../constants/themes"; // --- Defaults --- const defaultVisualizerSettings: VisualizerSettings = { + enabled: true, bars: 32, sensitivity: 1, noiseReduction: 0.77, @@ -64,7 +65,18 @@ export async function loadAppStateFromFile(): Promise { const cfg = await loadConfig(); if (!cfg || typeof cfg !== "object") return defaultState; return { - settings: { ...defaultSettings, ...cfg.settings }, + settings: { + ...defaultSettings, + ...cfg.settings, + // Visualizer is nested: a plain spread would let a config + // saved before a field was added (e.g. `enabled`) clobber + // the whole object and leave the new field undefined. + // Deep-merge so defaults backfill missing nested keys. + visualizer: { + ...defaultVisualizerSettings, + ...cfg.settings?.visualizer, + }, + }, preferences: { ...defaultPreferences, ...cfg.preferences }, customTheme: { ...DEFAULT_THEME, ...cfg.customTheme }, }; diff --git a/tests/visualizer-store.test.ts b/tests/visualizer-store.test.ts index 6b3570e..053d37b 100644 --- a/tests/visualizer-store.test.ts +++ b/tests/visualizer-store.test.ts @@ -27,6 +27,7 @@ import { test, expect, afterAll } from "bun:test"; import { tmpdir } from "os"; import { join } from "path"; import { setIsPlaying, setPosition, setCurrentEpisode } from "../src/utils/audio-signals"; +import { useAppStore } from "../src/stores/app"; import type { Episode } from "../src/types/episode"; // ── Sandbox (the app store reads config from XDG_CONFIG_HOME at first @@ -214,6 +215,30 @@ test.skipIf(skip)( { timeout: 45000 }, ); +// Settings master switch: turning the visualizer off must tear the running +// pipeline down (not just hide the component), and re-enabling restarts it +// from the current position. +test.skipIf(skip)( + "disabling the visualizer stops a running pipeline; re-enabling restarts it", + async () => { + const viz = useVisualizer(); + const app = useAppStore(); + await app.whenReady(); + await startPlaying(); + expect(viz.isRunning()).toBe(true); + + app.updateVisualizer({ enabled: false }); + await waitFor(() => !viz.isRunning(), 10000); + expect(viz.isLoading()).toBe(false); + + app.updateVisualizer({ enabled: true }); + await waitFor(() => viz.isRunning(), 10000); + await waitFor(() => !viz.isLoading() && viz.barData().length > 0, 10000); + expect(viz.barData().length).toBe(64); + }, + { timeout: 20000 }, +); + // ── Teardown ───────────────────────────────────────────────────────────── afterAll(() => { diff --git a/tests/visualizer-toggle.test.ts b/tests/visualizer-toggle.test.ts new file mode 100644 index 0000000..db3b079 --- /dev/null +++ b/tests/visualizer-toggle.test.ts @@ -0,0 +1,94 @@ +/** + * visualizer-toggle.test.ts — "waveform visualizer toggleable on/off in + * settings, default on". + * + * Pins three contracts: + * 1. The default is ON (fresh config, before any user change). + * 2. The Settings → Visualizer "Waveform" item flips it via updateVisualizer. + * 3. Persistence: a config saved BEFORE `enabled` existed (no key) still + * loads as ON with its other visualizer fields intact (deep-merge + * backfill), and an explicit `enabled: false` survives a reload. + * + * The config dir is derived from XDG_CONFIG_HOME at call time, so each + * persistence test points it at a fresh tmpdir and writes its own + * config.json before calling loadAppStateFromFile directly. + */ +import { test, expect } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync } 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-viz-toggle-")); +process.env.XDG_CONFIG_HOME = CONFIG; +process.env.XDG_DATA_HOME = mkdtempSync(join(tmpdir(), "podtui-viz-toggle-data-")); +process.env.PODTUI_AUDIO_BACKEND = "none"; + +const { useAppStore } = await import("../src/stores/app"); +const { useVisualizerItems } = await import( + "../src/pages/Settings/VisualizerSettings" +); +const { loadAppStateFromFile } = await import("../src/utils/app-persistence"); + +/** Write a config.json into a fresh XDG_CONFIG_HOME and load app state. */ +async function loadWithConfig(settings: unknown): Promise<{ + state: ReturnType extends Promise + ? T + : never; +}> { + const dir = mkdtempSync(join(tmpdir(), "podtui-viz-toggle-cfg-")); + process.env.XDG_CONFIG_HOME = dir; + mkdirSync(join(dir, "podtui"), { recursive: true }); + writeFileSync( + join(dir, "podtui", "config.json"), + JSON.stringify({ settings }, null, 2), + ); + return { state: await loadAppStateFromFile() }; +} + +test("waveform visualizer defaults to ON with a fresh config", async () => { + const app = useAppStore(); + await app.whenReady(); // empty sandbox config → defaults + expect(app.state().settings.visualizer.enabled).toBe(true); +}); + +test("Settings → Visualizer exposes a Waveform toggle that flips the setting", async () => { + const app = useAppStore(); + await app.whenReady(); + const items = useVisualizerItems(); + const item = items.find((it) => it.id === "enabled"); + expect(item).toBeDefined(); + expect(item!.kind).toBe("toggle"); + expect(item!.display()).toBe("On"); + + item!.toggle!(); + expect(app.state().settings.visualizer.enabled).toBe(false); + expect(item!.display()).toBe("Off"); + + item!.toggle!(); + expect(app.state().settings.visualizer.enabled).toBe(true); + expect(item!.display()).toBe("On"); +}); + +test("a config saved before `enabled` existed loads as ON with other fields intact", async () => { + const { state } = await loadWithConfig({ + visualizer: { bars: 16, lowCutOff: 80 }, + }); + expect(state.settings.visualizer.enabled).toBe(true); // backfilled + expect(state.settings.visualizer.bars).toBe(16); // preserved, not clobbered + expect(state.settings.visualizer.lowCutOff).toBe(80); +}); + +test("an explicit enabled:false survives a reload", async () => { + const { state } = await loadWithConfig({ + visualizer: { enabled: false, bars: 128 }, + }); + expect(state.settings.visualizer.enabled).toBe(false); + expect(state.settings.visualizer.bars).toBe(128); +}); + +test("an empty visualizer object in config falls back to full defaults", async () => { + const { state } = await loadWithConfig({ visualizer: {} }); + expect(state.settings.visualizer.enabled).toBe(true); + expect(state.settings.visualizer.bars).toBeGreaterThan(0); +});