veza/apps/web/src/features/player/components/AudioSettingsPanel.tsx
senke 260e668615 feat(v0.13.1): conformité audio & player — gapless, crossfade, normalization
TASK-AUDIO-001: Enhanced gapless playback with 10s pre-buffering
TASK-AUDIO-002: Crossfade UI in expanded player (0-12s configurable slider)
TASK-AUDIO-003: Audio normalization via Web Audio API GainNode (EBU R128)
TASK-AUDIO-004: Complete player features (playback speed, preload, fade)

- AudioPlayerService: added normalization gain node, connectAudioGraph(),
  setNormalizationGain(), setNormalizationEnabled() with dB-to-linear conversion
- useAudioAnalyser: integrated with gain node for correct audio graph routing
- useAudioNormalization: new hook syncing normalization state with track changes
- PlayerStore: added normalizationEnabled setting (persisted)
- AudioSettingsPanel: new component with crossfade slider + normalization toggle
- PlayerExpanded: added audio settings panel with Settings2 icon toggle
- GlobalPlayer: integrated useAudioNormalization hook
- usePlayer: extended pre-buffer window from 5s to 10s for gapless playback
- 97 tests passing (56 service + 41 store)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:38:44 +01:00

90 lines
3.4 KiB
TypeScript

/**
* AudioSettingsPanel — v0.13.1 TASK-AUDIO-002 + TASK-AUDIO-003
*
* Configurable crossfade (0-12s) and audio normalization toggle.
* Appears in the expanded player and settings.
*/
import { usePlayerStore } from '../store/playerStore';
import { cn } from '@/lib/utils';
import { Slider } from '@/components/ui/slider';
import { Tooltip } from '@/components/ui/tooltip';
import { Volume2, AudioWaveform } from 'lucide-react';
interface AudioSettingsPanelProps {
className?: string;
compact?: boolean;
}
export function AudioSettingsPanel({ className, compact = false }: AudioSettingsPanelProps) {
const crossfadeSeconds = usePlayerStore((s) => s.crossfadeSeconds);
const setCrossfadeSeconds = usePlayerStore((s) => s.setCrossfadeSeconds);
const normalizationEnabled = usePlayerStore((s) => s.normalizationEnabled);
const setNormalizationEnabled = usePlayerStore((s) => s.setNormalizationEnabled);
return (
<div className={cn('flex flex-col gap-4', className)}>
{/* Crossfade control */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-sm font-medium text-foreground">
<AudioWaveform className="w-4 h-4 text-muted-foreground" aria-hidden="true" />
Crossfade
</label>
<span className="text-xs text-muted-foreground font-mono">
{crossfadeSeconds === 0 ? 'Off' : `${crossfadeSeconds}s`}
</span>
</div>
<Slider
value={[crossfadeSeconds]}
onValueChange={(val) => setCrossfadeSeconds(val[0] ?? 0)}
min={0}
max={12}
step={1}
aria-label="Crossfade duration"
className={compact ? 'w-32' : undefined}
/>
{!compact && (
<p className="text-xs text-muted-foreground">
Smooth transition between tracks. Set to 0 for gapless playback.
</p>
)}
</div>
{/* Normalization toggle */}
<div className="flex items-center justify-between">
<label
htmlFor="normalization-toggle"
className="flex items-center gap-2 text-sm font-medium text-foreground cursor-pointer"
>
<Volume2 className="w-4 h-4 text-muted-foreground" aria-hidden="true" />
Volume normalization
</label>
<Tooltip content="Equalizes volume levels between tracks to prevent sudden jumps">
<button
id="normalization-toggle"
role="switch"
aria-checked={normalizationEnabled}
onClick={() => setNormalizationEnabled(!normalizationEnabled)}
className={cn(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-background',
normalizationEnabled ? 'bg-primary' : 'bg-muted',
)}
>
<span
className={cn(
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
normalizationEnabled ? 'translate-x-6' : 'translate-x-1',
)}
/>
</button>
</Tooltip>
</div>
{!compact && (
<p className="text-xs text-muted-foreground -mt-2">
Adjusts volume to consistent levels across all tracks (EBU R128).
</p>
)}
</div>
);
}