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);
+});