feat(player): click-to-seek progress bar; 2-row waveform bars with peak normalization

- ProgressBar: full-width played/remaining bar in the player pane, click-to-seek;
  waveform no longer handles seeking or the played/future color split (pure visual)
- bars: 2 terminal rows per bar (16 levels) via barChars, partial block in the
  top row so the column renders continuously
- fix bars maxing at audio start: disable cava autosens (silence gain-ramp),
  pre-warm the malloc'd FFT window with zeros, skip partial FFT windows
- createBarScaler peak follower + power curve replaces cava autosens for
  level-to-height mapping (src/utils/bar-mapping.ts, unit-tested)
This commit is contained in:
2026-08-10 16:49:57 -04:00
parent 0f3ffcf934
commit 67032460ff
5 changed files with 295 additions and 64 deletions

View File

@@ -0,0 +1,56 @@
/**
* ProgressBar — one-row, click-to-seek playback progress bar for the
* player pane. Played portion renders as full blocks (█) in the theme's
* primary color, the remainder as light shade blocks (░) in the muted
* color. The header time/percent text lives in PlayerPage — this is only
* the bar itself.
*/
import { useTerminalDimensions } from "@opentui/solid";
import { useAudio } from "@/hooks/useAudio";
import { useTheme } from "@/context/ThemeContext";
// ── Component ────────────────────────────────────────────────────────
export function ProgressBar() {
const audio = useAudio();
const { theme } = useTheme();
const dimensions = useTerminalDimensions();
// Full content width of the player pane: the player is a 2-pane row
// (parent 1/5 + current 4/5 of the terminal width). Subtract ~8 chars
// of border/padding chrome (same math as RealtimeWaveform's numBars).
const width = () => Math.max(8, Math.floor((dimensions().width * 4) / 5) - 8);
const clamp01 = (value: number) => Math.max(0, Math.min(1, value));
const playedChars = () => {
const duration = audio.duration();
if (duration <= 0) return 0;
return Math.round(clamp01(audio.position() / duration) * width());
};
const remainingColor = theme.muted || theme.text;
return (
<box
border
borderColor={theme.border}
padding={0}
flexDirection="row"
gap={0}
onMouseDown={(e: { x: number }) => {
const duration = audio.duration();
if (duration <= 0) return;
// e.x = 0 is the box border; content starts at x = 1.
const ratio = Math.max(0, Math.min(1, (e.x - 1) / width()));
void audio.seek(ratio * duration);
}}
>
<text fg={theme.primary}>{"\u2588".repeat(playedChars())}</text>
<text fg={remainingColor}>
{"\u2591".repeat(width() - playedChars())}
</text>
</box>
);
}