@djangocfg/ui-nextjs 2.1.82 → 2.1.84
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/README.md +108 -242
- package/src/tools/AudioPlayer/components/HybridAudioPlayer.tsx +216 -0
- package/src/tools/AudioPlayer/components/{SimpleAudioPlayer.tsx → HybridSimplePlayer.tsx} +61 -69
- package/src/tools/AudioPlayer/components/HybridWaveform.tsx +279 -0
- package/src/tools/AudioPlayer/components/ReactiveCover/AudioReactiveCover.tsx +5 -5
- package/src/tools/AudioPlayer/components/index.ts +7 -6
- package/src/tools/AudioPlayer/context/HybridAudioProvider.tsx +121 -0
- package/src/tools/AudioPlayer/context/index.ts +11 -6
- package/src/tools/AudioPlayer/hooks/index.ts +14 -10
- package/src/tools/AudioPlayer/hooks/useHybridAudio.ts +387 -0
- package/src/tools/AudioPlayer/hooks/{useAudioAnalysis.ts → useHybridAudioAnalysis.ts} +23 -38
- package/src/tools/AudioPlayer/index.ts +37 -70
- package/src/tools/AudioPlayer/types/index.ts +10 -18
- package/src/tools/index.ts +60 -43
- package/src/tools/AudioPlayer/@refactoring/00-PLAN.md +0 -148
- package/src/tools/AudioPlayer/@refactoring/01-TYPES.md +0 -301
- package/src/tools/AudioPlayer/@refactoring/02-HOOKS.md +0 -281
- package/src/tools/AudioPlayer/@refactoring/03-CONTEXT.md +0 -328
- package/src/tools/AudioPlayer/@refactoring/04-COMPONENTS.md +0 -251
- package/src/tools/AudioPlayer/@refactoring/05-EFFECTS.md +0 -427
- package/src/tools/AudioPlayer/@refactoring/06-UTILS-AND-INDEX.md +0 -193
- package/src/tools/AudioPlayer/@refactoring/07-EXECUTION-CHECKLIST.md +0 -146
- package/src/tools/AudioPlayer/@refactoring2/ISSUE_ANALYSIS.md +0 -187
- package/src/tools/AudioPlayer/@refactoring2/PLAN.md +0 -372
- package/src/tools/AudioPlayer/components/AudioEqualizer.tsx +0 -200
- package/src/tools/AudioPlayer/components/AudioPlayer.tsx +0 -231
- package/src/tools/AudioPlayer/components/AudioShortcutsPopover.tsx +0 -99
- package/src/tools/AudioPlayer/components/VisualizationToggle.tsx +0 -64
- package/src/tools/AudioPlayer/context/AudioProvider.tsx +0 -371
- package/src/tools/AudioPlayer/context/selectors.ts +0 -96
- package/src/tools/AudioPlayer/hooks/useAudioHotkeys.ts +0 -150
- package/src/tools/AudioPlayer/hooks/useAudioSource.ts +0 -155
- package/src/tools/AudioPlayer/hooks/useSharedWebAudio.ts +0 -106
- package/src/tools/AudioPlayer/progressive/ProgressiveAudioPlayer.tsx +0 -295
- package/src/tools/AudioPlayer/progressive/WaveformCanvas.tsx +0 -381
- package/src/tools/AudioPlayer/progressive/index.ts +0 -40
- package/src/tools/AudioPlayer/progressive/peaks.ts +0 -234
- package/src/tools/AudioPlayer/progressive/types.ts +0 -179
- package/src/tools/AudioPlayer/progressive/useAudioElement.ts +0 -340
- package/src/tools/AudioPlayer/progressive/useProgressiveWaveform.ts +0 -267
- package/src/tools/AudioPlayer/types/audio.ts +0 -121
- package/src/tools/AudioPlayer/types/components.ts +0 -98
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* useAudioSource - Handles audio source loading with optional prefetch
|
|
5
|
-
*
|
|
6
|
-
* For streaming URLs, WaveSurfer needs the complete file to enable seeking.
|
|
7
|
-
* This hook fetches the URL as blob when prefetch is enabled.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { useState, useEffect, useRef } from 'react';
|
|
11
|
-
import type { AudioSource } from '../types';
|
|
12
|
-
import { audioDebug } from '../utils/debug';
|
|
13
|
-
|
|
14
|
-
export interface UseAudioSourceResult {
|
|
15
|
-
/** The resolved URL (blob URL if prefetched, original URL otherwise) */
|
|
16
|
-
url: string | null;
|
|
17
|
-
/** Whether the source is currently being fetched */
|
|
18
|
-
isLoading: boolean;
|
|
19
|
-
/** Error message if fetch failed */
|
|
20
|
-
error: string | null;
|
|
21
|
-
/** Progress percentage (0-100) during fetch */
|
|
22
|
-
progress: number;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function useAudioSource(source: AudioSource): UseAudioSourceResult {
|
|
26
|
-
const [url, setUrl] = useState<string | null>(null);
|
|
27
|
-
const [isLoading, setIsLoading] = useState(false);
|
|
28
|
-
const [error, setError] = useState<string | null>(null);
|
|
29
|
-
const [progress, setProgress] = useState(0);
|
|
30
|
-
const blobUrlRef = useRef<string | null>(null);
|
|
31
|
-
|
|
32
|
-
useEffect(() => {
|
|
33
|
-
// Cleanup previous blob URL
|
|
34
|
-
if (blobUrlRef.current) {
|
|
35
|
-
URL.revokeObjectURL(blobUrlRef.current);
|
|
36
|
-
blobUrlRef.current = null;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// Reset state
|
|
40
|
-
setError(null);
|
|
41
|
-
setProgress(0);
|
|
42
|
-
|
|
43
|
-
// No prefetch - use URL directly
|
|
44
|
-
if (!source.prefetch) {
|
|
45
|
-
audioDebug.debug('Using direct URL (prefetch disabled)', { uri: source.uri });
|
|
46
|
-
setUrl(source.uri);
|
|
47
|
-
setIsLoading(false);
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Prefetch enabled - fetch as blob
|
|
52
|
-
const abortController = new AbortController();
|
|
53
|
-
setIsLoading(true);
|
|
54
|
-
|
|
55
|
-
const fetchAsBlob = async () => {
|
|
56
|
-
try {
|
|
57
|
-
audioDebug.info('Prefetching audio as blob', { uri: source.uri });
|
|
58
|
-
|
|
59
|
-
const response = await fetch(source.uri, {
|
|
60
|
-
signal: abortController.signal,
|
|
61
|
-
headers: {
|
|
62
|
-
// Request full file - some servers require Range header
|
|
63
|
-
'Range': 'bytes=0-',
|
|
64
|
-
},
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
// Accept 200 OK or 206 Partial Content (response.ok covers both)
|
|
68
|
-
if (!response.ok) {
|
|
69
|
-
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Get content length for progress tracking
|
|
73
|
-
// For Range requests, use Content-Range header (format: "bytes 0-1234/5678")
|
|
74
|
-
let totalBytes = 0;
|
|
75
|
-
const contentRange = response.headers.get('Content-Range');
|
|
76
|
-
if (contentRange) {
|
|
77
|
-
const match = contentRange.match(/\/(\d+)$/);
|
|
78
|
-
if (match) {
|
|
79
|
-
totalBytes = parseInt(match[1], 10);
|
|
80
|
-
}
|
|
81
|
-
} else {
|
|
82
|
-
const contentLength = response.headers.get('Content-Length');
|
|
83
|
-
totalBytes = contentLength ? parseInt(contentLength, 10) : 0;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
if (!response.body) {
|
|
87
|
-
// Fallback for browsers without ReadableStream
|
|
88
|
-
const blob = await response.blob();
|
|
89
|
-
const blobUrl = URL.createObjectURL(blob);
|
|
90
|
-
blobUrlRef.current = blobUrl;
|
|
91
|
-
setUrl(blobUrl);
|
|
92
|
-
setProgress(100);
|
|
93
|
-
audioDebug.success('Audio prefetched (no stream)', { size: blob.size });
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Stream the response for progress tracking
|
|
98
|
-
const reader = response.body.getReader();
|
|
99
|
-
const chunks: ArrayBuffer[] = [];
|
|
100
|
-
let receivedBytes = 0;
|
|
101
|
-
|
|
102
|
-
while (true) {
|
|
103
|
-
const { done, value } = await reader.read();
|
|
104
|
-
|
|
105
|
-
if (done) break;
|
|
106
|
-
|
|
107
|
-
// Convert Uint8Array to ArrayBuffer for Blob compatibility
|
|
108
|
-
chunks.push(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength));
|
|
109
|
-
receivedBytes += value.length;
|
|
110
|
-
|
|
111
|
-
if (totalBytes > 0) {
|
|
112
|
-
setProgress(Math.round((receivedBytes / totalBytes) * 100));
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Combine chunks into blob
|
|
117
|
-
const blob = new Blob(chunks, { type: 'audio/mpeg' });
|
|
118
|
-
const blobUrl = URL.createObjectURL(blob);
|
|
119
|
-
blobUrlRef.current = blobUrl;
|
|
120
|
-
setUrl(blobUrl);
|
|
121
|
-
setProgress(100);
|
|
122
|
-
|
|
123
|
-
audioDebug.success('Audio prefetched', {
|
|
124
|
-
size: blob.size,
|
|
125
|
-
sizeFormatted: `${(blob.size / 1024 / 1024).toFixed(2)} MB`,
|
|
126
|
-
});
|
|
127
|
-
} catch (err) {
|
|
128
|
-
if (err instanceof Error && err.name === 'AbortError') {
|
|
129
|
-
return; // Ignore abort errors
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const errorMessage = err instanceof Error ? err.message : 'Failed to prefetch audio';
|
|
133
|
-
audioDebug.error('Failed to prefetch audio', { error: errorMessage, uri: source.uri });
|
|
134
|
-
setError(errorMessage);
|
|
135
|
-
|
|
136
|
-
// Fallback to direct URL (may have seek issues)
|
|
137
|
-
setUrl(source.uri);
|
|
138
|
-
} finally {
|
|
139
|
-
setIsLoading(false);
|
|
140
|
-
}
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
fetchAsBlob();
|
|
144
|
-
|
|
145
|
-
return () => {
|
|
146
|
-
abortController.abort();
|
|
147
|
-
if (blobUrlRef.current) {
|
|
148
|
-
URL.revokeObjectURL(blobUrlRef.current);
|
|
149
|
-
blobUrlRef.current = null;
|
|
150
|
-
}
|
|
151
|
-
};
|
|
152
|
-
}, [source.uri, source.prefetch]);
|
|
153
|
-
|
|
154
|
-
return { url, isLoading, error, progress };
|
|
155
|
-
}
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* useSharedWebAudio - Manages a shared Web Audio context and source node.
|
|
5
|
-
*
|
|
6
|
-
* This prevents the "InvalidStateError" from creating multiple MediaElementSourceNodes
|
|
7
|
-
* for the same audio element. All analyzers share the same source.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { useRef, useEffect, useCallback } from 'react';
|
|
11
|
-
import type { SharedWebAudioContext } from '../types';
|
|
12
|
-
|
|
13
|
-
export function useSharedWebAudio(audioElement: HTMLMediaElement | null): SharedWebAudioContext {
|
|
14
|
-
const audioContextRef = useRef<AudioContext | null>(null);
|
|
15
|
-
const sourceRef = useRef<MediaElementAudioSourceNode | null>(null);
|
|
16
|
-
const connectedElementRef = useRef<HTMLMediaElement | null>(null);
|
|
17
|
-
const analyserNodesRef = useRef<Set<AnalyserNode>>(new Set());
|
|
18
|
-
|
|
19
|
-
// Initialize Web Audio on first play
|
|
20
|
-
useEffect(() => {
|
|
21
|
-
if (!audioElement) return;
|
|
22
|
-
|
|
23
|
-
// Already connected to this element
|
|
24
|
-
if (connectedElementRef.current === audioElement && audioContextRef.current) {
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const initAudio = () => {
|
|
29
|
-
try {
|
|
30
|
-
if (!audioContextRef.current) {
|
|
31
|
-
const AudioContextClass = window.AudioContext ||
|
|
32
|
-
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
|
33
|
-
audioContextRef.current = new AudioContextClass();
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const audioContext = audioContextRef.current;
|
|
37
|
-
|
|
38
|
-
// Only create source node once per audio element
|
|
39
|
-
if (connectedElementRef.current !== audioElement) {
|
|
40
|
-
if (sourceRef.current) {
|
|
41
|
-
try { sourceRef.current.disconnect(); } catch { /* ignore */ }
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
sourceRef.current = audioContext.createMediaElementSource(audioElement);
|
|
45
|
-
// Connect directly to destination (analysers will be inserted in between)
|
|
46
|
-
sourceRef.current.connect(audioContext.destination);
|
|
47
|
-
connectedElementRef.current = audioElement;
|
|
48
|
-
}
|
|
49
|
-
} catch (error) {
|
|
50
|
-
console.warn('[SharedWebAudio] Could not initialize:', error);
|
|
51
|
-
}
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const handlePlay = () => {
|
|
55
|
-
initAudio();
|
|
56
|
-
if (audioContextRef.current?.state === 'suspended') {
|
|
57
|
-
audioContextRef.current.resume();
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
audioElement.addEventListener('play', handlePlay);
|
|
62
|
-
if (!audioElement.paused) {
|
|
63
|
-
handlePlay();
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return () => {
|
|
67
|
-
audioElement.removeEventListener('play', handlePlay);
|
|
68
|
-
};
|
|
69
|
-
}, [audioElement]);
|
|
70
|
-
|
|
71
|
-
// Create an analyser connected to the shared source
|
|
72
|
-
const createAnalyser = useCallback((options?: { fftSize?: number; smoothing?: number }): AnalyserNode | null => {
|
|
73
|
-
if (!audioContextRef.current || !sourceRef.current) return null;
|
|
74
|
-
|
|
75
|
-
try {
|
|
76
|
-
const analyser = audioContextRef.current.createAnalyser();
|
|
77
|
-
analyser.fftSize = options?.fftSize ?? 256;
|
|
78
|
-
analyser.smoothingTimeConstant = options?.smoothing ?? 0.85;
|
|
79
|
-
|
|
80
|
-
// Connect: source -> analyser -> destination
|
|
81
|
-
sourceRef.current.connect(analyser);
|
|
82
|
-
analyser.connect(audioContextRef.current.destination);
|
|
83
|
-
|
|
84
|
-
analyserNodesRef.current.add(analyser);
|
|
85
|
-
return analyser;
|
|
86
|
-
} catch (error) {
|
|
87
|
-
console.warn('[SharedWebAudio] Could not create analyser:', error);
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
90
|
-
}, []);
|
|
91
|
-
|
|
92
|
-
// Disconnect an analyser
|
|
93
|
-
const disconnectAnalyser = useCallback((analyser: AnalyserNode) => {
|
|
94
|
-
try {
|
|
95
|
-
analyser.disconnect();
|
|
96
|
-
analyserNodesRef.current.delete(analyser);
|
|
97
|
-
} catch { /* ignore */ }
|
|
98
|
-
}, []);
|
|
99
|
-
|
|
100
|
-
return {
|
|
101
|
-
audioContext: audioContextRef.current,
|
|
102
|
-
sourceNode: sourceRef.current,
|
|
103
|
-
createAnalyser,
|
|
104
|
-
disconnectAnalyser,
|
|
105
|
-
};
|
|
106
|
-
}
|
|
@@ -1,295 +0,0 @@
|
|
|
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;
|