@djangocfg/ui-nextjs 2.1.70 → 2.1.71

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.
@@ -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';