@djangocfg/ui-nextjs 2.1.65 → 2.1.66
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 +9 -6
- package/src/blocks/SplitHero/SplitHeroMedia.tsx +2 -1
- package/src/tools/AudioPlayer/AudioEqualizer.tsx +235 -0
- package/src/tools/AudioPlayer/AudioPlayer.tsx +223 -0
- package/src/tools/AudioPlayer/AudioReactiveCover.tsx +389 -0
- package/src/tools/AudioPlayer/AudioShortcutsPopover.tsx +95 -0
- package/src/tools/AudioPlayer/README.md +301 -0
- package/src/tools/AudioPlayer/SimpleAudioPlayer.tsx +275 -0
- package/src/tools/AudioPlayer/VisualizationToggle.tsx +68 -0
- package/src/tools/AudioPlayer/context.tsx +426 -0
- package/src/tools/AudioPlayer/effects/index.ts +412 -0
- package/src/tools/AudioPlayer/index.ts +84 -0
- package/src/tools/AudioPlayer/types.ts +162 -0
- package/src/tools/AudioPlayer/useAudioHotkeys.ts +142 -0
- package/src/tools/AudioPlayer/useAudioVisualization.tsx +195 -0
- package/src/tools/ImageViewer/ImageViewer.tsx +416 -0
- package/src/tools/ImageViewer/README.md +161 -0
- package/src/tools/ImageViewer/index.ts +16 -0
- package/src/tools/VideoPlayer/README.md +196 -187
- package/src/tools/VideoPlayer/VideoErrorFallback.tsx +174 -0
- package/src/tools/VideoPlayer/VideoPlayer.tsx +189 -218
- package/src/tools/VideoPlayer/VideoPlayerContext.tsx +125 -0
- package/src/tools/VideoPlayer/index.ts +59 -7
- package/src/tools/VideoPlayer/providers/NativeProvider.tsx +206 -0
- package/src/tools/VideoPlayer/providers/StreamProvider.tsx +311 -0
- package/src/tools/VideoPlayer/providers/VidstackProvider.tsx +254 -0
- package/src/tools/VideoPlayer/providers/index.ts +8 -0
- package/src/tools/VideoPlayer/types.ts +320 -71
- package/src/tools/index.ts +82 -4
- package/src/tools/VideoPlayer/NativePlayer.tsx +0 -141
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-nextjs",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.66",
|
|
4
4
|
"description": "Next.js UI component library with Radix UI primitives, Tailwind CSS styling, charts, and form components",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-components",
|
|
@@ -58,8 +58,8 @@
|
|
|
58
58
|
"check": "tsc --noEmit"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
|
-
"@djangocfg/api": "^2.1.
|
|
62
|
-
"@djangocfg/ui-core": "^2.1.
|
|
61
|
+
"@djangocfg/api": "^2.1.66",
|
|
62
|
+
"@djangocfg/ui-core": "^2.1.66",
|
|
63
63
|
"@types/react": "^19.1.0",
|
|
64
64
|
"@types/react-dom": "^19.1.0",
|
|
65
65
|
"consola": "^3.4.2",
|
|
@@ -78,16 +78,17 @@
|
|
|
78
78
|
"@radix-ui/react-menubar": "^1.1.16",
|
|
79
79
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
|
80
80
|
"@radix-ui/react-slot": "^1.2.4",
|
|
81
|
-
"input-otp": "1.4.2",
|
|
82
81
|
"@rjsf/core": "^6.1.2",
|
|
83
82
|
"@rjsf/utils": "^6.1.2",
|
|
84
83
|
"@rjsf/validator-ajv8": "^6.1.2",
|
|
85
84
|
"@vidstack/react": "next",
|
|
85
|
+
"@wavesurfer/react": "^1.0.12",
|
|
86
86
|
"@web3icons/react": "^4.0.26",
|
|
87
87
|
"chart.js": "^4.5.0",
|
|
88
88
|
"class-variance-authority": "^0.7.1",
|
|
89
89
|
"cytoscape": "^3.33.1",
|
|
90
90
|
"cytoscape-cose-bilkent": "^4.1.0",
|
|
91
|
+
"input-otp": "1.4.2",
|
|
91
92
|
"libphonenumber-js": "^1.12.24",
|
|
92
93
|
"media-icons": "next",
|
|
93
94
|
"mermaid": "^11.12.0",
|
|
@@ -100,13 +101,15 @@
|
|
|
100
101
|
"react-lottie-player": "^2.1.0",
|
|
101
102
|
"react-markdown": "10.1.0",
|
|
102
103
|
"react-sticky-box": "^2.0.5",
|
|
104
|
+
"react-zoom-pan-pinch": "^3.7.0",
|
|
103
105
|
"recharts": "2.15.4",
|
|
104
106
|
"remark-gfm": "4.0.1",
|
|
105
107
|
"sonner": "2.0.7",
|
|
106
|
-
"vidstack": "next"
|
|
108
|
+
"vidstack": "next",
|
|
109
|
+
"wavesurfer.js": "^7.12.1"
|
|
107
110
|
},
|
|
108
111
|
"devDependencies": {
|
|
109
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
112
|
+
"@djangocfg/typescript-config": "^2.1.66",
|
|
110
113
|
"@types/node": "^24.7.2",
|
|
111
114
|
"eslint": "^9.37.0",
|
|
112
115
|
"tailwindcss-animate": "1.0.7",
|
|
@@ -41,13 +41,14 @@ export const SplitHeroMedia: React.FC<SplitHeroMediaProps> = ({
|
|
|
41
41
|
<div className={containerClass}>
|
|
42
42
|
<VideoPlayer
|
|
43
43
|
source={{
|
|
44
|
+
type: 'url',
|
|
44
45
|
url: media.url,
|
|
45
46
|
title: media.title,
|
|
46
47
|
poster: media.poster,
|
|
47
48
|
}}
|
|
48
49
|
theme="modern"
|
|
49
50
|
aspectRatio={16 / 9}
|
|
50
|
-
|
|
51
|
+
autoPlay={media.autoplay}
|
|
51
52
|
muted={media.muted ?? media.autoplay}
|
|
52
53
|
/>
|
|
53
54
|
</div>
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AudioEqualizer - Real-time frequency visualizer with animated bars
|
|
5
|
+
*
|
|
6
|
+
* Uses Web Audio API AnalyserNode for real-time frequency analysis
|
|
7
|
+
* Renders animated vertical bars that respond to audio playback
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - Real-time frequency visualization
|
|
11
|
+
* - Configurable number of bars
|
|
12
|
+
* - Theme-aware colors (dark/light support)
|
|
13
|
+
* - Smooth animations with CSS transitions
|
|
14
|
+
* - Peak hold indicators
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
18
|
+
import { cn } from '@djangocfg/ui-nextjs';
|
|
19
|
+
import { useAudioElement } from './context';
|
|
20
|
+
import type { AudioEqualizerProps } from './types';
|
|
21
|
+
|
|
22
|
+
// =============================================================================
|
|
23
|
+
// CONSTANTS
|
|
24
|
+
// =============================================================================
|
|
25
|
+
|
|
26
|
+
const DEFAULT_BAR_COUNT = 24;
|
|
27
|
+
const DEFAULT_HEIGHT = 48;
|
|
28
|
+
const DEFAULT_GAP = 2;
|
|
29
|
+
const PEAK_DECAY_RATE = 0.02;
|
|
30
|
+
const PEAK_HOLD_TIME = 500;
|
|
31
|
+
|
|
32
|
+
// =============================================================================
|
|
33
|
+
// COMPONENT
|
|
34
|
+
// =============================================================================
|
|
35
|
+
|
|
36
|
+
export function AudioEqualizer({
|
|
37
|
+
barCount = DEFAULT_BAR_COUNT,
|
|
38
|
+
height = DEFAULT_HEIGHT,
|
|
39
|
+
gap = DEFAULT_GAP,
|
|
40
|
+
showPeaks = true,
|
|
41
|
+
barColor,
|
|
42
|
+
peakColor,
|
|
43
|
+
className,
|
|
44
|
+
}: AudioEqualizerProps) {
|
|
45
|
+
// Get audio element and playing state from context
|
|
46
|
+
const { audioElement, isPlaying } = useAudioElement();
|
|
47
|
+
|
|
48
|
+
const [frequencies, setFrequencies] = useState<number[]>(() =>
|
|
49
|
+
new Array(barCount).fill(0)
|
|
50
|
+
);
|
|
51
|
+
const [peaks, setPeaks] = useState<number[]>(() =>
|
|
52
|
+
new Array(barCount).fill(0)
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// Refs for Web Audio API
|
|
56
|
+
const audioContextRef = useRef<AudioContext | null>(null);
|
|
57
|
+
const analyserRef = useRef<AnalyserNode | null>(null);
|
|
58
|
+
const sourceRef = useRef<MediaElementAudioSourceNode | null>(null);
|
|
59
|
+
const animationRef = useRef<number | null>(null);
|
|
60
|
+
const peakTimersRef = useRef<number[]>(new Array(barCount).fill(0));
|
|
61
|
+
const connectedElementRef = useRef<HTMLMediaElement | null>(null);
|
|
62
|
+
|
|
63
|
+
// Cleanup function
|
|
64
|
+
const cleanup = useCallback(() => {
|
|
65
|
+
if (animationRef.current) {
|
|
66
|
+
cancelAnimationFrame(animationRef.current);
|
|
67
|
+
animationRef.current = null;
|
|
68
|
+
}
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
// Initialize Web Audio API
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!audioElement) {
|
|
74
|
+
cleanup();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Avoid reconnecting same element
|
|
79
|
+
if (connectedElementRef.current === audioElement && audioContextRef.current) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const initAudio = () => {
|
|
84
|
+
try {
|
|
85
|
+
if (!audioContextRef.current) {
|
|
86
|
+
audioContextRef.current = new AudioContext();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const audioContext = audioContextRef.current;
|
|
90
|
+
|
|
91
|
+
if (!analyserRef.current) {
|
|
92
|
+
analyserRef.current = audioContext.createAnalyser();
|
|
93
|
+
analyserRef.current.fftSize = 64;
|
|
94
|
+
analyserRef.current.smoothingTimeConstant = 0.8;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (connectedElementRef.current !== audioElement) {
|
|
98
|
+
if (sourceRef.current) {
|
|
99
|
+
try {
|
|
100
|
+
sourceRef.current.disconnect();
|
|
101
|
+
} catch {
|
|
102
|
+
// Ignore disconnect errors
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
sourceRef.current = audioContext.createMediaElementSource(audioElement);
|
|
107
|
+
sourceRef.current.connect(analyserRef.current);
|
|
108
|
+
analyserRef.current.connect(audioContext.destination);
|
|
109
|
+
connectedElementRef.current = audioElement;
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.warn('AudioEqualizer: Could not connect to audio element', error);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const handlePlay = () => {
|
|
117
|
+
initAudio();
|
|
118
|
+
if (audioContextRef.current?.state === 'suspended') {
|
|
119
|
+
audioContextRef.current.resume();
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
audioElement.addEventListener('play', handlePlay);
|
|
124
|
+
|
|
125
|
+
if (!audioElement.paused) {
|
|
126
|
+
handlePlay();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return () => {
|
|
130
|
+
audioElement.removeEventListener('play', handlePlay);
|
|
131
|
+
};
|
|
132
|
+
}, [audioElement, cleanup]);
|
|
133
|
+
|
|
134
|
+
// Animation loop
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (!isPlaying || !analyserRef.current) {
|
|
137
|
+
cleanup();
|
|
138
|
+
setFrequencies(new Array(barCount).fill(0));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const analyser = analyserRef.current;
|
|
143
|
+
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
|
144
|
+
|
|
145
|
+
const animate = () => {
|
|
146
|
+
analyser.getByteFrequencyData(dataArray);
|
|
147
|
+
|
|
148
|
+
const step = Math.floor(dataArray.length / barCount);
|
|
149
|
+
const newFrequencies: number[] = [];
|
|
150
|
+
const newPeaks: number[] = [...peaks];
|
|
151
|
+
const now = Date.now();
|
|
152
|
+
|
|
153
|
+
for (let i = 0; i < barCount; i++) {
|
|
154
|
+
let sum = 0;
|
|
155
|
+
for (let j = 0; j < step; j++) {
|
|
156
|
+
sum += dataArray[i * step + j] || 0;
|
|
157
|
+
}
|
|
158
|
+
const value = sum / step / 255;
|
|
159
|
+
newFrequencies.push(value);
|
|
160
|
+
|
|
161
|
+
if (showPeaks) {
|
|
162
|
+
if (value > newPeaks[i]) {
|
|
163
|
+
newPeaks[i] = value;
|
|
164
|
+
peakTimersRef.current[i] = now;
|
|
165
|
+
} else if (now - peakTimersRef.current[i] > PEAK_HOLD_TIME) {
|
|
166
|
+
newPeaks[i] = Math.max(0, newPeaks[i] - PEAK_DECAY_RATE);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
setFrequencies(newFrequencies);
|
|
172
|
+
if (showPeaks) {
|
|
173
|
+
setPeaks(newPeaks);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
animationRef.current = requestAnimationFrame(animate);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
animationRef.current = requestAnimationFrame(animate);
|
|
180
|
+
|
|
181
|
+
return cleanup;
|
|
182
|
+
}, [isPlaying, barCount, showPeaks, cleanup, peaks]);
|
|
183
|
+
|
|
184
|
+
// Reset peaks array when barCount changes
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
setPeaks(new Array(barCount).fill(0));
|
|
187
|
+
peakTimersRef.current = new Array(barCount).fill(0);
|
|
188
|
+
}, [barCount]);
|
|
189
|
+
|
|
190
|
+
// Calculate bar width
|
|
191
|
+
const totalGaps = (barCount - 1) * gap;
|
|
192
|
+
const barWidth = `calc((100% - ${totalGaps}px) / ${barCount})`;
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<div
|
|
196
|
+
className={cn('relative flex items-end justify-between', className)}
|
|
197
|
+
style={{ height }}
|
|
198
|
+
>
|
|
199
|
+
{frequencies.map((freq, index) => (
|
|
200
|
+
<div
|
|
201
|
+
key={index}
|
|
202
|
+
className="relative flex flex-col justify-end"
|
|
203
|
+
style={{
|
|
204
|
+
width: barWidth,
|
|
205
|
+
height: '100%',
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
{/* Peak indicator */}
|
|
209
|
+
{showPeaks && peaks[index] > 0.01 && (
|
|
210
|
+
<div
|
|
211
|
+
className="absolute w-full transition-all duration-75"
|
|
212
|
+
style={{
|
|
213
|
+
height: 2,
|
|
214
|
+
bottom: `${peaks[index] * 100}%`,
|
|
215
|
+
backgroundColor: peakColor || 'hsl(217 91% 70%)',
|
|
216
|
+
}}
|
|
217
|
+
/>
|
|
218
|
+
)}
|
|
219
|
+
|
|
220
|
+
{/* Frequency bar */}
|
|
221
|
+
<div
|
|
222
|
+
className="w-full rounded-t-sm transition-all duration-75"
|
|
223
|
+
style={{
|
|
224
|
+
height: `${Math.max(freq * 100, 2)}%`,
|
|
225
|
+
backgroundColor: barColor || 'hsl(217 91% 60%)',
|
|
226
|
+
opacity: 0.3 + freq * 0.7,
|
|
227
|
+
}}
|
|
228
|
+
/>
|
|
229
|
+
</div>
|
|
230
|
+
))}
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export default AudioEqualizer;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AudioPlayer - Audio playback controls and waveform display
|
|
5
|
+
*
|
|
6
|
+
* Uses AudioContext for state management
|
|
7
|
+
* Renders waveform (via container ref), controls, timer, and equalizer
|
|
8
|
+
* Supports keyboard shortcuts for playback control
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { forwardRef } from 'react';
|
|
12
|
+
import {
|
|
13
|
+
Play,
|
|
14
|
+
Pause,
|
|
15
|
+
RotateCcw,
|
|
16
|
+
SkipBack,
|
|
17
|
+
SkipForward,
|
|
18
|
+
Volume2,
|
|
19
|
+
VolumeX,
|
|
20
|
+
Loader2,
|
|
21
|
+
} from 'lucide-react';
|
|
22
|
+
import { Button, Slider, cn } from '@djangocfg/ui-nextjs';
|
|
23
|
+
import { useAudio } from './context';
|
|
24
|
+
import { AudioEqualizer } from './AudioEqualizer';
|
|
25
|
+
import { useAudioHotkeys } from './useAudioHotkeys';
|
|
26
|
+
import { AudioShortcutsPopover } from './AudioShortcutsPopover';
|
|
27
|
+
import type { AudioPlayerProps } from './types';
|
|
28
|
+
|
|
29
|
+
// =============================================================================
|
|
30
|
+
// HELPERS
|
|
31
|
+
// =============================================================================
|
|
32
|
+
|
|
33
|
+
function formatTime(seconds: number): string {
|
|
34
|
+
if (!seconds || !isFinite(seconds) || seconds < 0) return '0:00';
|
|
35
|
+
const mins = Math.floor(seconds / 60);
|
|
36
|
+
const secs = Math.floor(seconds % 60);
|
|
37
|
+
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// =============================================================================
|
|
41
|
+
// COMPONENT
|
|
42
|
+
// =============================================================================
|
|
43
|
+
|
|
44
|
+
export const AudioPlayer = forwardRef<HTMLDivElement, AudioPlayerProps>(
|
|
45
|
+
function AudioPlayer(
|
|
46
|
+
{
|
|
47
|
+
showControls = true,
|
|
48
|
+
showWaveform = true,
|
|
49
|
+
showEqualizer = false,
|
|
50
|
+
showTimer = true,
|
|
51
|
+
showVolume = true,
|
|
52
|
+
equalizerOptions = {},
|
|
53
|
+
className,
|
|
54
|
+
style,
|
|
55
|
+
},
|
|
56
|
+
ref
|
|
57
|
+
) {
|
|
58
|
+
// Get all state and controls from context
|
|
59
|
+
const {
|
|
60
|
+
isReady,
|
|
61
|
+
isPlaying,
|
|
62
|
+
currentTime,
|
|
63
|
+
duration,
|
|
64
|
+
volume,
|
|
65
|
+
isMuted,
|
|
66
|
+
togglePlay,
|
|
67
|
+
restart,
|
|
68
|
+
skip,
|
|
69
|
+
setVolume,
|
|
70
|
+
toggleMute,
|
|
71
|
+
} = useAudio();
|
|
72
|
+
|
|
73
|
+
// Enable keyboard shortcuts
|
|
74
|
+
useAudioHotkeys({ enabled: isReady });
|
|
75
|
+
|
|
76
|
+
const isLoading = !isReady;
|
|
77
|
+
|
|
78
|
+
const handleVolumeChange = (value: number[]) => {
|
|
79
|
+
setVolume(value[0] / 100);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
className={cn(
|
|
85
|
+
'flex flex-col gap-3 p-4 rounded-lg bg-card border',
|
|
86
|
+
className
|
|
87
|
+
)}
|
|
88
|
+
style={style}
|
|
89
|
+
>
|
|
90
|
+
{/* Waveform container - rendered by WaveSurfer via context */}
|
|
91
|
+
{showWaveform && (
|
|
92
|
+
<div className="relative">
|
|
93
|
+
<div
|
|
94
|
+
ref={ref}
|
|
95
|
+
className={cn(
|
|
96
|
+
'w-full rounded transition-opacity',
|
|
97
|
+
isLoading && 'opacity-50'
|
|
98
|
+
)}
|
|
99
|
+
/>
|
|
100
|
+
{isLoading && (
|
|
101
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
102
|
+
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
{/* Equalizer animation */}
|
|
109
|
+
{showEqualizer && (
|
|
110
|
+
<AudioEqualizer
|
|
111
|
+
barCount={equalizerOptions.barCount}
|
|
112
|
+
height={equalizerOptions.height}
|
|
113
|
+
gap={equalizerOptions.gap}
|
|
114
|
+
showPeaks={equalizerOptions.showPeaks}
|
|
115
|
+
barColor={equalizerOptions.barColor}
|
|
116
|
+
peakColor={equalizerOptions.peakColor}
|
|
117
|
+
className="px-1"
|
|
118
|
+
/>
|
|
119
|
+
)}
|
|
120
|
+
|
|
121
|
+
{/* Timer */}
|
|
122
|
+
{showTimer && (
|
|
123
|
+
<div className="flex justify-between text-xs text-muted-foreground tabular-nums px-1">
|
|
124
|
+
<span>{formatTime(currentTime)}</span>
|
|
125
|
+
<span>{formatTime(duration)}</span>
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{/* Controls */}
|
|
130
|
+
{showControls && (
|
|
131
|
+
<div className="flex items-center justify-center gap-1">
|
|
132
|
+
{/* Restart */}
|
|
133
|
+
<Button
|
|
134
|
+
variant="ghost"
|
|
135
|
+
size="icon"
|
|
136
|
+
className="h-9 w-9"
|
|
137
|
+
onClick={restart}
|
|
138
|
+
disabled={!isReady}
|
|
139
|
+
title="Restart"
|
|
140
|
+
>
|
|
141
|
+
<RotateCcw className="h-4 w-4" />
|
|
142
|
+
</Button>
|
|
143
|
+
|
|
144
|
+
{/* Skip back 5s */}
|
|
145
|
+
<Button
|
|
146
|
+
variant="ghost"
|
|
147
|
+
size="icon"
|
|
148
|
+
className="h-9 w-9"
|
|
149
|
+
onClick={() => skip(-5)}
|
|
150
|
+
disabled={!isReady}
|
|
151
|
+
title="Back 5 seconds"
|
|
152
|
+
>
|
|
153
|
+
<SkipBack className="h-4 w-4" />
|
|
154
|
+
</Button>
|
|
155
|
+
|
|
156
|
+
{/* Play/Pause */}
|
|
157
|
+
<Button
|
|
158
|
+
variant="default"
|
|
159
|
+
size="icon"
|
|
160
|
+
className="h-12 w-12 rounded-full"
|
|
161
|
+
onClick={togglePlay}
|
|
162
|
+
disabled={!isReady && !isLoading}
|
|
163
|
+
title={isPlaying ? 'Pause' : 'Play'}
|
|
164
|
+
>
|
|
165
|
+
{isLoading ? (
|
|
166
|
+
<Loader2 className="h-5 w-5 animate-spin" />
|
|
167
|
+
) : isPlaying ? (
|
|
168
|
+
<Pause className="h-5 w-5" />
|
|
169
|
+
) : (
|
|
170
|
+
<Play className="h-5 w-5 ml-0.5" />
|
|
171
|
+
)}
|
|
172
|
+
</Button>
|
|
173
|
+
|
|
174
|
+
{/* Skip forward 5s */}
|
|
175
|
+
<Button
|
|
176
|
+
variant="ghost"
|
|
177
|
+
size="icon"
|
|
178
|
+
className="h-9 w-9"
|
|
179
|
+
onClick={() => skip(5)}
|
|
180
|
+
disabled={!isReady}
|
|
181
|
+
title="Forward 5 seconds"
|
|
182
|
+
>
|
|
183
|
+
<SkipForward className="h-4 w-4" />
|
|
184
|
+
</Button>
|
|
185
|
+
|
|
186
|
+
{/* Volume */}
|
|
187
|
+
{showVolume && (
|
|
188
|
+
<>
|
|
189
|
+
<Button
|
|
190
|
+
variant="ghost"
|
|
191
|
+
size="icon"
|
|
192
|
+
className="h-9 w-9"
|
|
193
|
+
onClick={toggleMute}
|
|
194
|
+
title={isMuted ? 'Unmute' : 'Mute'}
|
|
195
|
+
>
|
|
196
|
+
{isMuted || volume === 0 ? (
|
|
197
|
+
<VolumeX className="h-4 w-4" />
|
|
198
|
+
) : (
|
|
199
|
+
<Volume2 className="h-4 w-4" />
|
|
200
|
+
)}
|
|
201
|
+
</Button>
|
|
202
|
+
|
|
203
|
+
<Slider
|
|
204
|
+
value={[isMuted ? 0 : volume * 100]}
|
|
205
|
+
max={100}
|
|
206
|
+
step={1}
|
|
207
|
+
onValueChange={handleVolumeChange}
|
|
208
|
+
className="w-20"
|
|
209
|
+
aria-label="Volume"
|
|
210
|
+
/>
|
|
211
|
+
</>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
{/* Shortcuts help */}
|
|
215
|
+
<AudioShortcutsPopover compact />
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
export default AudioPlayer;
|