From f8537d6aebb69255869ded4bb358ede73f23639d Mon Sep 17 00:00:00 2001 From: Raj Gara Date: Thu, 29 Jan 2026 20:54:54 -0500 Subject: [PATCH] feat: add full variant to AudioPlayer with waveform and progress bar --- .../AudioPlayer/AudioPlayer.stories.tsx | 54 ++++++- src/components/AudioPlayer/AudioPlayer.tsx | 138 +++++++++++++++--- src/components/AudioPlayer/index.ts | 2 + 3 files changed, 171 insertions(+), 23 deletions(-) diff --git a/src/components/AudioPlayer/AudioPlayer.stories.tsx b/src/components/AudioPlayer/AudioPlayer.stories.tsx index e4691bc..cb4ead6 100644 --- a/src/components/AudioPlayer/AudioPlayer.stories.tsx +++ b/src/components/AudioPlayer/AudioPlayer.stories.tsx @@ -147,7 +147,7 @@ const meta: Meta = { argTypes: { variant: { control: 'select', - options: ['inline', 'compact', 'waveform'], + options: ['inline', 'compact', 'waveform', 'full'], description: 'Visual variant of the player', }, size: { @@ -254,6 +254,58 @@ export const WaveformWithTitle: Story = { ), }; +// ============================================================================ +// Full Variant (Waveform + Progress Bar) +// ============================================================================ + +/** + * The Full variant combines a waveform visualization with a progress bar scrubber. + * Both are kept in sync - clicking on either one seeks to that position. + */ +export const Full: Story = { + render: () => ( +
+ +
+ ), +}; + +/** + * Full variant with title and playback rate control. + */ +export const FullWithTitle: Story = { + render: () => ( +
+ +
+ ), +}; + +/** + * Full variant with custom waveform colors. + */ +export const FullCustomColors: Story = { + render: () => ( +
+ +
+ ), +}; + // ============================================================================ // Size Variants // ============================================================================ diff --git a/src/components/AudioPlayer/AudioPlayer.tsx b/src/components/AudioPlayer/AudioPlayer.tsx index d27e0b7..ac142d7 100644 --- a/src/components/AudioPlayer/AudioPlayer.tsx +++ b/src/components/AudioPlayer/AudioPlayer.tsx @@ -68,6 +68,11 @@ const audioPlayerVariants = cva('', { 'rounded-xl border border-border', 'bg-card text-card-foreground', ], + full: [ + 'flex flex-col gap-3 p-4', + 'rounded-xl border border-border', + 'bg-card text-card-foreground', + ], }, size: { sm: '', @@ -80,6 +85,8 @@ const audioPlayerVariants = cva('', { { variant: 'compact', size: 'lg', class: 'p-4 gap-4' }, { variant: 'waveform', size: 'sm', class: 'p-3 gap-2' }, { variant: 'waveform', size: 'lg', class: 'p-5 gap-4' }, + { variant: 'full', size: 'sm', class: 'p-3 gap-2' }, + { variant: 'full', size: 'lg', class: 'p-5 gap-4' }, ], defaultVariants: { variant: 'compact', @@ -112,6 +119,11 @@ const playButtonVariants = cva( 'hover:bg-primary-700', 'active:bg-primary-800', ], + full: [ + 'bg-primary-600 text-white', + 'hover:bg-primary-700', + 'active:bg-primary-800', + ], }, size: { sm: 'h-7 w-7', @@ -246,11 +258,11 @@ function ProgressBar({ }} >
@@ -261,6 +273,14 @@ function ProgressBar({ // Waveform Component (lazy-loaded WaveSurfer) // ============================================================================ +/** Handle for imperative control of the Waveform component */ +export interface WaveformHandle { + /** Seek to a specific time in seconds */ + seekTo: (time: number) => void; + /** Get the current WaveSurfer instance (for advanced usage) */ + getInstance: () => unknown | null; +} + interface WaveformProps { src: string; isPlaying: boolean; @@ -274,22 +294,42 @@ interface WaveformProps { height?: number; } -function Waveform({ - src, - isPlaying, - playbackRate = 1, +const Waveform = React.forwardRef(function Waveform( + { + src, + isPlaying, + playbackRate = 1, onReady, onTimeUpdate, onFinish, onSeek, waveColor, progressColor, - height = 64, -}: WaveformProps) { + height = 64, + }, + ref +) { const containerRef = React.useRef(null); // eslint-disable-next-line @typescript-eslint/no-explicit-any const wavesurferRef = React.useRef(null); const [isLoaded, setIsLoaded] = React.useState(false); + const durationRef = React.useRef(0); + + // Expose imperative handle for seeking from parent + React.useImperativeHandle(ref, () => ({ + seekTo: (time: number) => { + if (wavesurferRef.current) { + // Get current duration from WaveSurfer directly + const currentDuration = wavesurferRef.current.getDuration() || durationRef.current; + if (currentDuration > 0) { + // WaveSurfer seekTo uses a ratio (0-1), not seconds + const ratio = Math.max(0, Math.min(1, time / currentDuration)); + wavesurferRef.current.seekTo(ratio); + } + } + }, + getInstance: () => wavesurferRef.current, + }), [isLoaded]); // Re-create handle when loaded // Initialize WaveSurfer React.useEffect(() => { @@ -318,7 +358,8 @@ function Waveform({ wavesurferRef.current.on('ready', () => { setIsLoaded(true); - onReady(wavesurferRef.current.getDuration()); + durationRef.current = wavesurferRef.current.getDuration(); + onReady(durationRef.current); }); wavesurferRef.current.on('audioprocess', () => { @@ -326,11 +367,16 @@ function Waveform({ }); wavesurferRef.current.on('seeking', () => { - onTimeUpdate(wavesurferRef.current.getCurrentTime()); + const time = wavesurferRef.current.getCurrentTime(); + onTimeUpdate(time); + onSeek(time); }); - wavesurferRef.current.on('interaction', () => { - onSeek(wavesurferRef.current.getCurrentTime()); + // Handle seek events (fired after seeking is complete) + wavesurferRef.current.on('seek', () => { + const time = wavesurferRef.current.getCurrentTime(); + onTimeUpdate(time); + onSeek(time); }); wavesurferRef.current.on('finish', () => { @@ -381,7 +427,9 @@ function Waveform({ style={{ height }} /> ); -} +}); + +Waveform.displayName = 'Waveform'; // ============================================================================ // Main AudioPlayer Component @@ -432,6 +480,7 @@ function AudioPlayer({ const [playbackRate, setPlaybackRate] = React.useState(1); const [audioInitialized, setAudioInitialized] = React.useState(false); const audioRef = React.useRef(null); + const waveformRef = React.useRef(null); const isPlaying = state === 'playing'; const isLoading = state === 'loading'; @@ -447,7 +496,7 @@ function AudioPlayer({ // Initialize audio element (for non-waveform variants) const initAudio = React.useCallback(() => { - if (variant === 'waveform' || audioInitialized) return null; + if (variant === 'waveform' || variant === 'full' || audioInitialized) return null; const audio = new globalThis.Audio(src); audioRef.current = audio; @@ -487,7 +536,7 @@ function AudioPlayer({ // Auto-initialize if preload is true React.useEffect(() => { - if (preload && !audioInitialized && variant !== 'waveform') { + if (preload && !audioInitialized && variant !== 'waveform' && variant !== 'full') { initAudio(); } }, [preload, audioInitialized, variant, initAudio]); @@ -512,8 +561,8 @@ function AudioPlayer({ const handlePlay = React.useCallback(() => { if (disabled) return; - // Waveform variant uses WaveSurfer for playback - just toggle state - if (variant === 'waveform') { + // Waveform and full variants use WaveSurfer for playback - just toggle state + if (variant === 'waveform' || variant === 'full') { if (isLoading) return; updateState(isPlaying ? 'paused' : 'playing'); return; @@ -597,6 +646,12 @@ function AudioPlayer({ setCurrentTime(time); }, []); + // Handle seek from progress bar (for full variant) - sync with WaveSurfer + const handleFullVariantSeek = React.useCallback((time: number) => { + setCurrentTime(time); + waveformRef.current?.seekTo(time); + }, []); + const iconSize = size === 'sm' ? 'h-3.5 w-3.5' : size === 'lg' ? 'h-5 w-5' : 'h-4 w-4'; @@ -696,6 +751,40 @@ function AudioPlayer({ // ============================================================================ // Waveform Variant // ============================================================================ + if (variant === 'waveform') { + return ( +
+ {title && ( + + {title} + + )} + +
+ {renderPlayButton()} +
+ {renderTime()} + {renderPlaybackRateControl()} +
+
+
+ ); + } + + // ============================================================================ + // Full Variant (Waveform + Progress Bar) + // ============================================================================ return (
{title && ( @@ -704,6 +793,7 @@ function AudioPlayer({ )}
{renderPlayButton()} -
- {renderTime()} - {renderPlaybackRateControl()} -
+ + {renderTime()} + {renderPlaybackRateControl()}
); @@ -732,4 +826,4 @@ AudioPlayer.displayName = 'AudioPlayer'; // Exports // ============================================================================ -export { AudioPlayer, audioPlayerVariants, playButtonVariants, ProgressBar }; +export { AudioPlayer, audioPlayerVariants, playButtonVariants, ProgressBar, Waveform }; diff --git a/src/components/AudioPlayer/index.ts b/src/components/AudioPlayer/index.ts index ca348fb..ded8e08 100644 --- a/src/components/AudioPlayer/index.ts +++ b/src/components/AudioPlayer/index.ts @@ -3,7 +3,9 @@ export { audioPlayerVariants, playButtonVariants, ProgressBar, + Waveform, formatTime as formatAudioTime, type AudioPlayerProps, type AudioPlayerState, + type WaveformHandle, } from './AudioPlayer';