feat(player): toggleable waveform visualizer in settings, default on
This commit is contained in:
@@ -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() {
|
||||
|
||||
<ProgressBar />
|
||||
|
||||
<Show when={vizEnabled()}>
|
||||
<RealtimeWaveform />
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "../utils/app-persistence";
|
||||
|
||||
const defaultVisualizerSettings: VisualizerSettings = {
|
||||
enabled: true,
|
||||
bars: 64,
|
||||
sensitivity: 1,
|
||||
noiseReduction: 0.77,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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<AppState> {
|
||||
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 },
|
||||
};
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
94
tests/visualizer-toggle.test.ts
Normal file
94
tests/visualizer-toggle.test.ts
Normal file
@@ -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<typeof loadAppStateFromFile> extends Promise<infer T>
|
||||
? 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);
|
||||
});
|
||||
Reference in New Issue
Block a user