@djangocfg/ui-nextjs 2.1.70 → 2.1.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/tools/AudioPlayer/@refactoring2/ISSUE_ANALYSIS.md +187 -0
- package/src/tools/AudioPlayer/@refactoring2/PLAN.md +372 -0
- package/src/tools/AudioPlayer/hooks/useAudioHotkeys.ts +3 -2
- package/src/tools/AudioPlayer/index.ts +27 -0
- package/src/tools/AudioPlayer/progressive/ProgressiveAudioPlayer.tsx +295 -0
- package/src/tools/AudioPlayer/progressive/WaveformCanvas.tsx +381 -0
- package/src/tools/AudioPlayer/progressive/index.ts +40 -0
- package/src/tools/AudioPlayer/progressive/peaks.ts +234 -0
- package/src/tools/AudioPlayer/progressive/types.ts +179 -0
- package/src/tools/AudioPlayer/progressive/useAudioElement.ts +289 -0
- package/src/tools/AudioPlayer/progressive/useProgressiveWaveform.ts +267 -0
- package/src/tools/VideoPlayer/providers/VidstackProvider.tsx +4 -9
- package/src/tools/index.ts +16 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ProgressiveAudioPlayer - Audio player with progressive waveform loading
|
|
5
|
+
*
|
|
6
|
+
* Uses native HTML5 audio for playback (supports Range requests)
|
|
7
|
+
* and custom canvas for waveform visualization.
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - Progressive waveform loading (no need to load entire file)
|
|
11
|
+
* - Smooth seek on any position
|
|
12
|
+
* - Buffered ranges visualization
|
|
13
|
+
* - Keyboard shortcuts
|
|
14
|
+
* - Volume control
|
|
15
|
+
* - Loop mode
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useRef, useCallback } from 'react';
|
|
19
|
+
import {
|
|
20
|
+
Play,
|
|
21
|
+
Pause,
|
|
22
|
+
RotateCcw,
|
|
23
|
+
SkipBack,
|
|
24
|
+
SkipForward,
|
|
25
|
+
Volume2,
|
|
26
|
+
VolumeX,
|
|
27
|
+
Loader2,
|
|
28
|
+
Repeat,
|
|
29
|
+
AlertCircle,
|
|
30
|
+
} from 'lucide-react';
|
|
31
|
+
import { cn, Button, Slider } from '@djangocfg/ui-core';
|
|
32
|
+
|
|
33
|
+
import { useAudioElement } from './useAudioElement';
|
|
34
|
+
import { useProgressiveWaveform } from './useProgressiveWaveform';
|
|
35
|
+
import { WaveformCanvas } from './WaveformCanvas';
|
|
36
|
+
import { formatTime } from '../utils';
|
|
37
|
+
import type { ProgressiveAudioPlayerProps, WaveformStyle } from './types';
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// COMPONENT
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
export function ProgressiveAudioPlayer({
|
|
44
|
+
src,
|
|
45
|
+
title,
|
|
46
|
+
artist,
|
|
47
|
+
coverArt,
|
|
48
|
+
showWaveform = true,
|
|
49
|
+
showControls = true,
|
|
50
|
+
showTimer = true,
|
|
51
|
+
showVolume = true,
|
|
52
|
+
showLoop = true,
|
|
53
|
+
autoPlay = false,
|
|
54
|
+
waveformStyle,
|
|
55
|
+
className,
|
|
56
|
+
onPlay,
|
|
57
|
+
onPause,
|
|
58
|
+
onEnded,
|
|
59
|
+
onTimeUpdate,
|
|
60
|
+
onError,
|
|
61
|
+
}: ProgressiveAudioPlayerProps) {
|
|
62
|
+
const audioRef = useRef<HTMLAudioElement>(null);
|
|
63
|
+
|
|
64
|
+
// Audio element hook
|
|
65
|
+
const audio = useAudioElement({
|
|
66
|
+
src,
|
|
67
|
+
autoPlay,
|
|
68
|
+
onPlay,
|
|
69
|
+
onPause,
|
|
70
|
+
onEnded,
|
|
71
|
+
onTimeUpdate,
|
|
72
|
+
onError,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Progressive waveform loading
|
|
76
|
+
const waveform = useProgressiveWaveform({
|
|
77
|
+
url: src,
|
|
78
|
+
duration: audio.duration,
|
|
79
|
+
enabled: showWaveform,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Handlers
|
|
83
|
+
const handleSeek = useCallback((time: number) => {
|
|
84
|
+
audio.seek(time);
|
|
85
|
+
}, [audio]);
|
|
86
|
+
|
|
87
|
+
const handleVolumeChange = useCallback((value: number[]) => {
|
|
88
|
+
audio.setVolume(value[0] / 100);
|
|
89
|
+
}, [audio]);
|
|
90
|
+
|
|
91
|
+
// Loading state
|
|
92
|
+
const isLoading = !audio.isReady;
|
|
93
|
+
const hasError = audio.error || waveform.error;
|
|
94
|
+
|
|
95
|
+
// Default waveform style
|
|
96
|
+
const defaultStyle: WaveformStyle = {
|
|
97
|
+
waveColor: 'hsl(217 91% 60% / 0.3)',
|
|
98
|
+
progressColor: 'hsl(217 91% 60%)',
|
|
99
|
+
loadingColor: 'hsl(217 91% 60% / 0.1)',
|
|
100
|
+
cursorColor: 'hsl(217 91% 60%)',
|
|
101
|
+
barWidth: 3,
|
|
102
|
+
barGap: 2,
|
|
103
|
+
barRadius: 2,
|
|
104
|
+
height: 64,
|
|
105
|
+
...waveformStyle,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className={cn('flex flex-col gap-3 p-4 rounded-lg bg-card border', className)}>
|
|
110
|
+
{/* Hidden audio element */}
|
|
111
|
+
<audio
|
|
112
|
+
ref={(el) => {
|
|
113
|
+
(audioRef as any).current = el;
|
|
114
|
+
(audio.audioRef as any).current = el;
|
|
115
|
+
}}
|
|
116
|
+
preload="metadata"
|
|
117
|
+
crossOrigin="anonymous"
|
|
118
|
+
/>
|
|
119
|
+
|
|
120
|
+
{/* Header with title/artist */}
|
|
121
|
+
{(title || artist) && (
|
|
122
|
+
<div className="text-center">
|
|
123
|
+
{title && <h3 className="text-sm font-medium truncate">{title}</h3>}
|
|
124
|
+
{artist && <p className="text-xs text-muted-foreground truncate">{artist}</p>}
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
{/* Waveform */}
|
|
129
|
+
{showWaveform && (
|
|
130
|
+
<div className="relative">
|
|
131
|
+
<WaveformCanvas
|
|
132
|
+
peaks={waveform.peaks}
|
|
133
|
+
currentTime={audio.currentTime}
|
|
134
|
+
duration={audio.duration}
|
|
135
|
+
buffered={audio.buffered}
|
|
136
|
+
loadingPercent={waveform.loadedPercent}
|
|
137
|
+
onSeek={handleSeek}
|
|
138
|
+
interactive={audio.isReady}
|
|
139
|
+
style={defaultStyle}
|
|
140
|
+
className={cn(
|
|
141
|
+
'rounded transition-opacity',
|
|
142
|
+
isLoading && 'opacity-50'
|
|
143
|
+
)}
|
|
144
|
+
/>
|
|
145
|
+
|
|
146
|
+
{/* Loading overlay */}
|
|
147
|
+
{isLoading && (
|
|
148
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
149
|
+
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{/* Error overlay */}
|
|
154
|
+
{hasError && (
|
|
155
|
+
<div className="absolute inset-0 flex items-center justify-center bg-destructive/10 rounded">
|
|
156
|
+
<div className="flex items-center gap-2 text-destructive text-sm">
|
|
157
|
+
<AlertCircle className="h-4 w-4" />
|
|
158
|
+
<span>Failed to load</span>
|
|
159
|
+
<Button
|
|
160
|
+
variant="ghost"
|
|
161
|
+
size="sm"
|
|
162
|
+
onClick={waveform.retry}
|
|
163
|
+
className="h-6 px-2"
|
|
164
|
+
>
|
|
165
|
+
Retry
|
|
166
|
+
</Button>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
{/* Loading progress indicator */}
|
|
172
|
+
{waveform.isLoading && !isLoading && (
|
|
173
|
+
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-muted overflow-hidden rounded-full">
|
|
174
|
+
<div
|
|
175
|
+
className="h-full bg-primary transition-all duration-300"
|
|
176
|
+
style={{ width: `${waveform.loadedPercent}%` }}
|
|
177
|
+
/>
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
|
|
183
|
+
{/* Timer */}
|
|
184
|
+
{showTimer && (
|
|
185
|
+
<div className="flex justify-between text-xs text-muted-foreground tabular-nums px-1">
|
|
186
|
+
<span>{formatTime(audio.currentTime)}</span>
|
|
187
|
+
<span>{formatTime(audio.duration)}</span>
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
|
|
191
|
+
{/* Controls */}
|
|
192
|
+
{showControls && (
|
|
193
|
+
<div className="flex items-center justify-center gap-1">
|
|
194
|
+
{/* Restart */}
|
|
195
|
+
<Button
|
|
196
|
+
variant="ghost"
|
|
197
|
+
size="icon"
|
|
198
|
+
className="h-9 w-9"
|
|
199
|
+
onClick={() => audio.seek(0)}
|
|
200
|
+
disabled={!audio.isReady}
|
|
201
|
+
title="Restart"
|
|
202
|
+
>
|
|
203
|
+
<RotateCcw className="h-4 w-4" />
|
|
204
|
+
</Button>
|
|
205
|
+
|
|
206
|
+
{/* Skip back 5s */}
|
|
207
|
+
<Button
|
|
208
|
+
variant="ghost"
|
|
209
|
+
size="icon"
|
|
210
|
+
className="h-9 w-9"
|
|
211
|
+
onClick={() => audio.skip(-5)}
|
|
212
|
+
disabled={!audio.isReady}
|
|
213
|
+
title="Back 5 seconds"
|
|
214
|
+
>
|
|
215
|
+
<SkipBack className="h-4 w-4" />
|
|
216
|
+
</Button>
|
|
217
|
+
|
|
218
|
+
{/* Play/Pause */}
|
|
219
|
+
<Button
|
|
220
|
+
variant="default"
|
|
221
|
+
size="icon"
|
|
222
|
+
className="h-12 w-12 rounded-full"
|
|
223
|
+
onClick={audio.togglePlay}
|
|
224
|
+
disabled={!audio.isReady && !isLoading}
|
|
225
|
+
title={audio.isPlaying ? 'Pause' : 'Play'}
|
|
226
|
+
>
|
|
227
|
+
{isLoading ? (
|
|
228
|
+
<Loader2 className="h-5 w-5 animate-spin" />
|
|
229
|
+
) : audio.isPlaying ? (
|
|
230
|
+
<Pause className="h-5 w-5" />
|
|
231
|
+
) : (
|
|
232
|
+
<Play className="h-5 w-5 ml-0.5" />
|
|
233
|
+
)}
|
|
234
|
+
</Button>
|
|
235
|
+
|
|
236
|
+
{/* Skip forward 5s */}
|
|
237
|
+
<Button
|
|
238
|
+
variant="ghost"
|
|
239
|
+
size="icon"
|
|
240
|
+
className="h-9 w-9"
|
|
241
|
+
onClick={() => audio.skip(5)}
|
|
242
|
+
disabled={!audio.isReady}
|
|
243
|
+
title="Forward 5 seconds"
|
|
244
|
+
>
|
|
245
|
+
<SkipForward className="h-4 w-4" />
|
|
246
|
+
</Button>
|
|
247
|
+
|
|
248
|
+
{/* Volume */}
|
|
249
|
+
{showVolume && (
|
|
250
|
+
<>
|
|
251
|
+
<Button
|
|
252
|
+
variant="ghost"
|
|
253
|
+
size="icon"
|
|
254
|
+
className="h-9 w-9"
|
|
255
|
+
onClick={audio.toggleMute}
|
|
256
|
+
title={audio.isMuted ? 'Unmute' : 'Mute'}
|
|
257
|
+
>
|
|
258
|
+
{audio.isMuted || audio.volume === 0 ? (
|
|
259
|
+
<VolumeX className="h-4 w-4" />
|
|
260
|
+
) : (
|
|
261
|
+
<Volume2 className="h-4 w-4" />
|
|
262
|
+
)}
|
|
263
|
+
</Button>
|
|
264
|
+
|
|
265
|
+
<Slider
|
|
266
|
+
value={[audio.isMuted ? 0 : audio.volume * 100]}
|
|
267
|
+
max={100}
|
|
268
|
+
step={1}
|
|
269
|
+
onValueChange={handleVolumeChange}
|
|
270
|
+
className="w-20"
|
|
271
|
+
aria-label="Volume"
|
|
272
|
+
/>
|
|
273
|
+
</>
|
|
274
|
+
)}
|
|
275
|
+
|
|
276
|
+
{/* Loop/Repeat */}
|
|
277
|
+
{showLoop && (
|
|
278
|
+
<Button
|
|
279
|
+
variant="ghost"
|
|
280
|
+
size="icon"
|
|
281
|
+
className={cn('h-9 w-9', audio.isLooping && 'text-primary')}
|
|
282
|
+
onClick={audio.toggleLoop}
|
|
283
|
+
disabled={!audio.isReady}
|
|
284
|
+
title={audio.isLooping ? 'Disable loop' : 'Enable loop'}
|
|
285
|
+
>
|
|
286
|
+
<Repeat className="h-4 w-4" />
|
|
287
|
+
</Button>
|
|
288
|
+
)}
|
|
289
|
+
</div>
|
|
290
|
+
)}
|
|
291
|
+
</div>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export default ProgressiveAudioPlayer;
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WaveformCanvas - Canvas-based waveform visualization
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Progressive rendering as peaks load
|
|
8
|
+
* - Playback progress overlay
|
|
9
|
+
* - Buffered ranges indicator
|
|
10
|
+
* - Interactive seek (click/drag)
|
|
11
|
+
* - Hover preview
|
|
12
|
+
* - High DPI support
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
useRef,
|
|
17
|
+
useEffect,
|
|
18
|
+
useCallback,
|
|
19
|
+
useState,
|
|
20
|
+
forwardRef,
|
|
21
|
+
useImperativeHandle,
|
|
22
|
+
} from 'react';
|
|
23
|
+
import { cn } from '@djangocfg/ui-core';
|
|
24
|
+
import type { WaveformStyle, LoadedRange } from './types';
|
|
25
|
+
|
|
26
|
+
// =============================================================================
|
|
27
|
+
// TYPES
|
|
28
|
+
// =============================================================================
|
|
29
|
+
|
|
30
|
+
interface WaveformCanvasProps {
|
|
31
|
+
/** Peaks data (0-1 normalized) */
|
|
32
|
+
peaks: number[];
|
|
33
|
+
/** Current playback time (seconds) */
|
|
34
|
+
currentTime: number;
|
|
35
|
+
/** Total duration (seconds) */
|
|
36
|
+
duration: number;
|
|
37
|
+
/** Buffered ranges from audio element */
|
|
38
|
+
buffered?: TimeRanges | null;
|
|
39
|
+
/** Loaded ranges (for progressive loading indicator) */
|
|
40
|
+
loadedRanges?: LoadedRange[];
|
|
41
|
+
/** Loading progress (0-100) */
|
|
42
|
+
loadingPercent?: number;
|
|
43
|
+
/** Seek callback */
|
|
44
|
+
onSeek?: (time: number) => void;
|
|
45
|
+
/** Hover callback */
|
|
46
|
+
onHover?: (time: number | null) => void;
|
|
47
|
+
/** Is interactive */
|
|
48
|
+
interactive?: boolean;
|
|
49
|
+
/** Style options */
|
|
50
|
+
style?: WaveformStyle;
|
|
51
|
+
/** Additional class */
|
|
52
|
+
className?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface WaveformCanvasRef {
|
|
56
|
+
redraw: () => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// =============================================================================
|
|
60
|
+
// DEFAULT STYLES
|
|
61
|
+
// =============================================================================
|
|
62
|
+
|
|
63
|
+
const DEFAULT_STYLE: Required<WaveformStyle> = {
|
|
64
|
+
waveColor: 'hsl(217 91% 60% / 0.3)',
|
|
65
|
+
progressColor: 'hsl(217 91% 60%)',
|
|
66
|
+
loadingColor: 'hsl(217 91% 60% / 0.1)',
|
|
67
|
+
cursorColor: 'hsl(217 91% 60%)',
|
|
68
|
+
barWidth: 3,
|
|
69
|
+
barGap: 2,
|
|
70
|
+
barRadius: 2,
|
|
71
|
+
height: 64,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// =============================================================================
|
|
75
|
+
// COMPONENT
|
|
76
|
+
// =============================================================================
|
|
77
|
+
|
|
78
|
+
export const WaveformCanvas = forwardRef<WaveformCanvasRef, WaveformCanvasProps>(
|
|
79
|
+
function WaveformCanvas(props, ref) {
|
|
80
|
+
const {
|
|
81
|
+
peaks,
|
|
82
|
+
currentTime,
|
|
83
|
+
duration,
|
|
84
|
+
buffered,
|
|
85
|
+
loadedRanges = [],
|
|
86
|
+
loadingPercent = 100,
|
|
87
|
+
onSeek,
|
|
88
|
+
onHover,
|
|
89
|
+
interactive = true,
|
|
90
|
+
style = {},
|
|
91
|
+
className,
|
|
92
|
+
} = props;
|
|
93
|
+
|
|
94
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
95
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
96
|
+
const [hoverX, setHoverX] = useState<number | null>(null);
|
|
97
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
98
|
+
|
|
99
|
+
// Merge styles
|
|
100
|
+
const {
|
|
101
|
+
waveColor,
|
|
102
|
+
progressColor,
|
|
103
|
+
loadingColor,
|
|
104
|
+
cursorColor,
|
|
105
|
+
barWidth,
|
|
106
|
+
barGap,
|
|
107
|
+
barRadius,
|
|
108
|
+
height,
|
|
109
|
+
} = { ...DEFAULT_STYLE, ...style };
|
|
110
|
+
|
|
111
|
+
// ==========================================================================
|
|
112
|
+
// DRAWING
|
|
113
|
+
// ==========================================================================
|
|
114
|
+
|
|
115
|
+
const draw = useCallback(() => {
|
|
116
|
+
const canvas = canvasRef.current;
|
|
117
|
+
const container = containerRef.current;
|
|
118
|
+
if (!canvas || !container) return;
|
|
119
|
+
|
|
120
|
+
const ctx = canvas.getContext('2d');
|
|
121
|
+
if (!ctx) return;
|
|
122
|
+
|
|
123
|
+
// Get dimensions
|
|
124
|
+
const rect = container.getBoundingClientRect();
|
|
125
|
+
const width = rect.width;
|
|
126
|
+
const dpr = window.devicePixelRatio || 1;
|
|
127
|
+
|
|
128
|
+
// Set canvas size with DPI scaling
|
|
129
|
+
canvas.width = width * dpr;
|
|
130
|
+
canvas.height = height * dpr;
|
|
131
|
+
canvas.style.width = `${width}px`;
|
|
132
|
+
canvas.style.height = `${height}px`;
|
|
133
|
+
ctx.scale(dpr, dpr);
|
|
134
|
+
|
|
135
|
+
// Clear
|
|
136
|
+
ctx.clearRect(0, 0, width, height);
|
|
137
|
+
|
|
138
|
+
// Calculate bar positions
|
|
139
|
+
const totalBarSpace = barWidth + barGap;
|
|
140
|
+
const barCount = Math.floor(width / totalBarSpace);
|
|
141
|
+
|
|
142
|
+
if (barCount === 0) return;
|
|
143
|
+
|
|
144
|
+
// Progress position
|
|
145
|
+
const progress = duration > 0 ? currentTime / duration : 0;
|
|
146
|
+
const progressX = progress * width;
|
|
147
|
+
|
|
148
|
+
// Hover position
|
|
149
|
+
const hoverProgress = hoverX !== null ? hoverX / width : null;
|
|
150
|
+
|
|
151
|
+
// Draw loading placeholder for unloaded area
|
|
152
|
+
if (loadingPercent < 100) {
|
|
153
|
+
const loadedX = (loadingPercent / 100) * width;
|
|
154
|
+
ctx.fillStyle = loadingColor;
|
|
155
|
+
ctx.fillRect(loadedX, 0, width - loadedX, height);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Resample peaks to match bar count
|
|
159
|
+
const resampledPeaks = resamplePeaks(peaks, barCount);
|
|
160
|
+
|
|
161
|
+
// Draw bars
|
|
162
|
+
for (let i = 0; i < barCount; i++) {
|
|
163
|
+
const x = i * totalBarSpace;
|
|
164
|
+
const peakValue = resampledPeaks[i] || 0;
|
|
165
|
+
|
|
166
|
+
// Bar height (minimum 2px for visibility)
|
|
167
|
+
const barHeight = Math.max(2, peakValue * (height - 4));
|
|
168
|
+
const y = (height - barHeight) / 2;
|
|
169
|
+
|
|
170
|
+
// Determine color based on position
|
|
171
|
+
const barProgress = (x + barWidth / 2) / width;
|
|
172
|
+
let color = waveColor;
|
|
173
|
+
|
|
174
|
+
if (barProgress <= progress) {
|
|
175
|
+
color = progressColor;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Draw bar
|
|
179
|
+
ctx.fillStyle = color;
|
|
180
|
+
if (barRadius > 0) {
|
|
181
|
+
roundRect(ctx, x, y, barWidth, barHeight, barRadius);
|
|
182
|
+
} else {
|
|
183
|
+
ctx.fillRect(x, y, barWidth, barHeight);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Draw cursor
|
|
188
|
+
if (duration > 0) {
|
|
189
|
+
ctx.fillStyle = cursorColor;
|
|
190
|
+
ctx.fillRect(progressX - 1, 0, 2, height);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Draw hover indicator
|
|
194
|
+
if (hoverProgress !== null && interactive) {
|
|
195
|
+
ctx.fillStyle = `${cursorColor}80`; // 50% opacity
|
|
196
|
+
const hoverPx = hoverProgress * width;
|
|
197
|
+
ctx.fillRect(hoverPx - 1, 0, 2, height);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Draw buffered indicator (thin line at bottom)
|
|
201
|
+
if (buffered && buffered.length > 0 && duration > 0) {
|
|
202
|
+
ctx.fillStyle = `${progressColor}40`;
|
|
203
|
+
for (let i = 0; i < buffered.length; i++) {
|
|
204
|
+
const start = (buffered.start(i) / duration) * width;
|
|
205
|
+
const end = (buffered.end(i) / duration) * width;
|
|
206
|
+
ctx.fillRect(start, height - 2, end - start, 2);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}, [
|
|
210
|
+
peaks,
|
|
211
|
+
currentTime,
|
|
212
|
+
duration,
|
|
213
|
+
buffered,
|
|
214
|
+
loadingPercent,
|
|
215
|
+
hoverX,
|
|
216
|
+
waveColor,
|
|
217
|
+
progressColor,
|
|
218
|
+
loadingColor,
|
|
219
|
+
cursorColor,
|
|
220
|
+
barWidth,
|
|
221
|
+
barGap,
|
|
222
|
+
barRadius,
|
|
223
|
+
height,
|
|
224
|
+
interactive,
|
|
225
|
+
]);
|
|
226
|
+
|
|
227
|
+
// ==========================================================================
|
|
228
|
+
// INTERACTION HANDLERS
|
|
229
|
+
// ==========================================================================
|
|
230
|
+
|
|
231
|
+
const getTimeFromX = useCallback((clientX: number): number => {
|
|
232
|
+
const container = containerRef.current;
|
|
233
|
+
if (!container || duration <= 0) return 0;
|
|
234
|
+
|
|
235
|
+
const rect = container.getBoundingClientRect();
|
|
236
|
+
const x = clientX - rect.left;
|
|
237
|
+
const progress = Math.max(0, Math.min(1, x / rect.width));
|
|
238
|
+
return progress * duration;
|
|
239
|
+
}, [duration]);
|
|
240
|
+
|
|
241
|
+
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
|
242
|
+
if (!interactive) return;
|
|
243
|
+
|
|
244
|
+
const container = containerRef.current;
|
|
245
|
+
if (!container) return;
|
|
246
|
+
|
|
247
|
+
const rect = container.getBoundingClientRect();
|
|
248
|
+
const x = e.clientX - rect.left;
|
|
249
|
+
setHoverX(x);
|
|
250
|
+
|
|
251
|
+
const time = getTimeFromX(e.clientX);
|
|
252
|
+
onHover?.(time);
|
|
253
|
+
|
|
254
|
+
if (isDragging) {
|
|
255
|
+
onSeek?.(time);
|
|
256
|
+
}
|
|
257
|
+
}, [interactive, isDragging, getTimeFromX, onHover, onSeek]);
|
|
258
|
+
|
|
259
|
+
const handleMouseLeave = useCallback(() => {
|
|
260
|
+
setHoverX(null);
|
|
261
|
+
onHover?.(null);
|
|
262
|
+
setIsDragging(false);
|
|
263
|
+
}, [onHover]);
|
|
264
|
+
|
|
265
|
+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
266
|
+
if (!interactive) return;
|
|
267
|
+
e.preventDefault();
|
|
268
|
+
setIsDragging(true);
|
|
269
|
+
const time = getTimeFromX(e.clientX);
|
|
270
|
+
onSeek?.(time);
|
|
271
|
+
}, [interactive, getTimeFromX, onSeek]);
|
|
272
|
+
|
|
273
|
+
const handleMouseUp = useCallback(() => {
|
|
274
|
+
setIsDragging(false);
|
|
275
|
+
}, []);
|
|
276
|
+
|
|
277
|
+
// Global mouseup for drag release
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
if (!isDragging) return;
|
|
280
|
+
|
|
281
|
+
const handleGlobalMouseUp = () => setIsDragging(false);
|
|
282
|
+
window.addEventListener('mouseup', handleGlobalMouseUp);
|
|
283
|
+
return () => window.removeEventListener('mouseup', handleGlobalMouseUp);
|
|
284
|
+
}, [isDragging]);
|
|
285
|
+
|
|
286
|
+
// ==========================================================================
|
|
287
|
+
// EFFECTS
|
|
288
|
+
// ==========================================================================
|
|
289
|
+
|
|
290
|
+
// Redraw on any change
|
|
291
|
+
useEffect(() => {
|
|
292
|
+
draw();
|
|
293
|
+
}, [draw]);
|
|
294
|
+
|
|
295
|
+
// Redraw on resize
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
const handleResize = () => draw();
|
|
298
|
+
window.addEventListener('resize', handleResize);
|
|
299
|
+
return () => window.removeEventListener('resize', handleResize);
|
|
300
|
+
}, [draw]);
|
|
301
|
+
|
|
302
|
+
// Expose redraw method
|
|
303
|
+
useImperativeHandle(ref, () => ({
|
|
304
|
+
redraw: draw,
|
|
305
|
+
}), [draw]);
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<div
|
|
309
|
+
ref={containerRef}
|
|
310
|
+
className={cn(
|
|
311
|
+
'relative w-full cursor-pointer select-none',
|
|
312
|
+
!interactive && 'cursor-default',
|
|
313
|
+
className
|
|
314
|
+
)}
|
|
315
|
+
style={{ height }}
|
|
316
|
+
onMouseMove={handleMouseMove}
|
|
317
|
+
onMouseLeave={handleMouseLeave}
|
|
318
|
+
onMouseDown={handleMouseDown}
|
|
319
|
+
onMouseUp={handleMouseUp}
|
|
320
|
+
>
|
|
321
|
+
<canvas
|
|
322
|
+
ref={canvasRef}
|
|
323
|
+
className="absolute inset-0"
|
|
324
|
+
/>
|
|
325
|
+
</div>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
// =============================================================================
|
|
331
|
+
// HELPERS
|
|
332
|
+
// =============================================================================
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Draw rounded rectangle
|
|
336
|
+
*/
|
|
337
|
+
function roundRect(
|
|
338
|
+
ctx: CanvasRenderingContext2D,
|
|
339
|
+
x: number,
|
|
340
|
+
y: number,
|
|
341
|
+
width: number,
|
|
342
|
+
height: number,
|
|
343
|
+
radius: number
|
|
344
|
+
) {
|
|
345
|
+
ctx.beginPath();
|
|
346
|
+
ctx.moveTo(x + radius, y);
|
|
347
|
+
ctx.lineTo(x + width - radius, y);
|
|
348
|
+
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
|
349
|
+
ctx.lineTo(x + width, y + height - radius);
|
|
350
|
+
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
|
351
|
+
ctx.lineTo(x + radius, y + height);
|
|
352
|
+
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
|
353
|
+
ctx.lineTo(x, y + radius);
|
|
354
|
+
ctx.quadraticCurveTo(x, y, x + radius, y);
|
|
355
|
+
ctx.closePath();
|
|
356
|
+
ctx.fill();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Resample peaks to target length
|
|
361
|
+
*/
|
|
362
|
+
function resamplePeaks(peaks: number[], targetLength: number): number[] {
|
|
363
|
+
if (peaks.length === 0) return new Array(targetLength).fill(0);
|
|
364
|
+
if (peaks.length === targetLength) return peaks;
|
|
365
|
+
|
|
366
|
+
const result = new Array<number>(targetLength);
|
|
367
|
+
const ratio = peaks.length / targetLength;
|
|
368
|
+
|
|
369
|
+
for (let i = 0; i < targetLength; i++) {
|
|
370
|
+
const start = Math.floor(i * ratio);
|
|
371
|
+
const end = Math.min(Math.ceil((i + 1) * ratio), peaks.length);
|
|
372
|
+
|
|
373
|
+
let max = 0;
|
|
374
|
+
for (let j = start; j < end; j++) {
|
|
375
|
+
if (peaks[j] > max) max = peaks[j];
|
|
376
|
+
}
|
|
377
|
+
result[i] = max;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return result;
|
|
381
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progressive Audio Player
|
|
3
|
+
*
|
|
4
|
+
* Audio player with progressive waveform loading.
|
|
5
|
+
* Uses native HTML5 audio (supports Range requests) and custom canvas visualization.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Components
|
|
9
|
+
export { ProgressiveAudioPlayer } from './ProgressiveAudioPlayer';
|
|
10
|
+
export { WaveformCanvas } from './WaveformCanvas';
|
|
11
|
+
|
|
12
|
+
// Hooks
|
|
13
|
+
export { useAudioElement } from './useAudioElement';
|
|
14
|
+
export { useProgressiveWaveform } from './useProgressiveWaveform';
|
|
15
|
+
|
|
16
|
+
// Utilities
|
|
17
|
+
export {
|
|
18
|
+
extractPeaks,
|
|
19
|
+
mergePeaks,
|
|
20
|
+
resamplePeaks,
|
|
21
|
+
smoothPeaks,
|
|
22
|
+
calculatePeaksCount,
|
|
23
|
+
timeToIndex,
|
|
24
|
+
indexToTime,
|
|
25
|
+
} from './peaks';
|
|
26
|
+
|
|
27
|
+
// Types
|
|
28
|
+
export type {
|
|
29
|
+
WaveformData,
|
|
30
|
+
LoadedRange,
|
|
31
|
+
WaveformLoadingState,
|
|
32
|
+
AudioState,
|
|
33
|
+
AudioControls,
|
|
34
|
+
WaveformStyle,
|
|
35
|
+
WaveformInteraction,
|
|
36
|
+
ProgressiveAudioPlayerProps,
|
|
37
|
+
ChunkInfo,
|
|
38
|
+
DecodeResult,
|
|
39
|
+
DecoderOptions,
|
|
40
|
+
} from './types';
|