Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion src/components/AudioPlayer/AudioPlayer.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ const meta: Meta<typeof AudioPlayer> = {
argTypes: {
variant: {
control: 'select',
options: ['inline', 'compact', 'waveform'],
options: ['inline', 'compact', 'waveform', 'full'],
description: 'Visual variant of the player',
},
size: {
Expand Down Expand Up @@ -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: () => (
<div className="w-96">
<AudioPlayer src={getSampleAudio()} variant="full" showTime />
</div>
),
};

/**
* Full variant with title and playback rate control.
*/
export const FullWithTitle: Story = {
render: () => (
<div className="w-[500px]">
<AudioPlayer
src={getLongAudio()}
variant="full"
title="Podcast Episode - Tech Talk"
showTime
showPlaybackRate
/>
</div>
),
};

/**
* Full variant with custom waveform colors.
*/
export const FullCustomColors: Story = {
render: () => (
<div className="w-[500px]">
<AudioPlayer
src={getSampleAudio()}
variant="full"
title="Custom Styled Audio"
showTime
showPlaybackRate
waveColor="#cbd5e1"
progressColor="#8b5cf6"
/>
</div>
),
};

// ============================================================================
// Size Variants
// ============================================================================
Expand Down
138 changes: 116 additions & 22 deletions src/components/AudioPlayer/AudioPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@
'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: '',
Expand All @@ -80,6 +85,8 @@
{ 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',
Expand Down Expand Up @@ -112,6 +119,11 @@
'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',
Expand Down Expand Up @@ -246,11 +258,11 @@
}}
>
<div
className="bg-primary-600 absolute inset-y-0 left-0 rounded-full transition-all"
className="bg-primary-600 absolute inset-y-0 left-0 rounded-full"
style={{ width: `${progress}%` }}
/>
<div
className="bg-primary-600 absolute top-1/2 h-3 w-3 -translate-y-1/2 rounded-full shadow-sm transition-all"
className="bg-primary-600 absolute top-1/2 h-3 w-3 -translate-y-1/2 rounded-full shadow-sm"
style={{ left: `calc(${progress}% - 6px)` }}
/>
</div>
Expand All @@ -261,6 +273,14 @@
// 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;
Expand All @@ -274,22 +294,42 @@
height?: number;
}

function Waveform({
src,
isPlaying,
playbackRate = 1,
const Waveform = React.forwardRef<WaveformHandle, WaveformProps>(function Waveform(
{
src,
isPlaying,
playbackRate = 1,
onReady,
onTimeUpdate,
onFinish,
onSeek,
waveColor,
progressColor,
height = 64,
}: WaveformProps) {
height = 64,
},
ref
) {
const containerRef = React.useRef<HTMLDivElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const wavesurferRef = React.useRef<any>(null);
const [isLoaded, setIsLoaded] = React.useState(false);
const durationRef = React.useRef<number>(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

Check failure on line 332 in src/components/AudioPlayer/AudioPlayer.tsx

View workflow job for this annotation

GitHub Actions / Lint and Type Check

React Hook React.useImperativeHandle has an unnecessary dependency: 'isLoaded'. Either exclude it or remove the dependency array

// Initialize WaveSurfer
React.useEffect(() => {
Expand Down Expand Up @@ -318,19 +358,25 @@

wavesurferRef.current.on('ready', () => {
setIsLoaded(true);
onReady(wavesurferRef.current.getDuration());
durationRef.current = wavesurferRef.current.getDuration();
onReady(durationRef.current);
});

wavesurferRef.current.on('audioprocess', () => {
onTimeUpdate(wavesurferRef.current.getCurrentTime());
});

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', () => {
Expand Down Expand Up @@ -381,7 +427,9 @@
style={{ height }}
/>
);
}
});

Waveform.displayName = 'Waveform';

// ============================================================================
// Main AudioPlayer Component
Expand Down Expand Up @@ -432,6 +480,7 @@
const [playbackRate, setPlaybackRate] = React.useState(1);
const [audioInitialized, setAudioInitialized] = React.useState(false);
const audioRef = React.useRef<globalThis.HTMLAudioElement | null>(null);
const waveformRef = React.useRef<WaveformHandle>(null);

const isPlaying = state === 'playing';
const isLoading = state === 'loading';
Expand All @@ -447,7 +496,7 @@

// 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;
Expand Down Expand Up @@ -487,7 +536,7 @@

// 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]);
Expand All @@ -512,8 +561,8 @@
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;
Expand Down Expand Up @@ -597,6 +646,12 @@
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';

Expand Down Expand Up @@ -696,6 +751,40 @@
// ============================================================================
// Waveform Variant
// ============================================================================
if (variant === 'waveform') {
return (
<div className={cn(audioPlayerVariants({ variant, size }), className)}>
{title && (
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{title}
</span>
)}
<Waveform
src={src}
isPlaying={isPlaying}
playbackRate={playbackRate}
onReady={handleWaveformReady}
onTimeUpdate={handleWaveformTimeUpdate}
onFinish={handleWaveformFinish}
onSeek={handleWaveformSeek}
waveColor={waveColor}
progressColor={progressColor}
height={waveformHeight}
/>
<div className="flex items-center gap-3">
{renderPlayButton()}
<div className="flex flex-1 items-center justify-between">
{renderTime()}
{renderPlaybackRateControl()}
</div>
</div>
</div>
);
}

// ============================================================================
// Full Variant (Waveform + Progress Bar)
// ============================================================================
return (
<div className={cn(audioPlayerVariants({ variant, size }), className)}>
{title && (
Expand All @@ -704,6 +793,7 @@
</span>
)}
<Waveform
ref={waveformRef}
src={src}
isPlaying={isPlaying}
playbackRate={playbackRate}
Expand All @@ -717,10 +807,14 @@
/>
<div className="flex items-center gap-3">
{renderPlayButton()}
<div className="flex flex-1 items-center justify-between">
{renderTime()}
{renderPlaybackRateControl()}
</div>
<ProgressBar
currentTime={currentTime}
duration={duration}
onSeek={handleFullVariantSeek}
disabled={disabled}
/>
{renderTime()}
{renderPlaybackRateControl()}
</div>
</div>
);
Expand All @@ -732,4 +826,4 @@
// Exports
// ============================================================================

export { AudioPlayer, audioPlayerVariants, playButtonVariants, ProgressBar };
export { AudioPlayer, audioPlayerVariants, playButtonVariants, ProgressBar, Waveform };
2 changes: 2 additions & 0 deletions src/components/AudioPlayer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ export {
audioPlayerVariants,
playButtonVariants,
ProgressBar,
Waveform,
formatTime as formatAudioTime,
type AudioPlayerProps,
type AudioPlayerState,
type WaveformHandle,
} from './AudioPlayer';
Loading