@djangocfg/ui-nextjs 2.1.64 → 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.
Files changed (30) hide show
  1. package/package.json +9 -6
  2. package/src/blocks/SplitHero/SplitHeroMedia.tsx +2 -1
  3. package/src/tools/AudioPlayer/AudioEqualizer.tsx +235 -0
  4. package/src/tools/AudioPlayer/AudioPlayer.tsx +223 -0
  5. package/src/tools/AudioPlayer/AudioReactiveCover.tsx +389 -0
  6. package/src/tools/AudioPlayer/AudioShortcutsPopover.tsx +95 -0
  7. package/src/tools/AudioPlayer/README.md +301 -0
  8. package/src/tools/AudioPlayer/SimpleAudioPlayer.tsx +275 -0
  9. package/src/tools/AudioPlayer/VisualizationToggle.tsx +68 -0
  10. package/src/tools/AudioPlayer/context.tsx +426 -0
  11. package/src/tools/AudioPlayer/effects/index.ts +412 -0
  12. package/src/tools/AudioPlayer/index.ts +84 -0
  13. package/src/tools/AudioPlayer/types.ts +162 -0
  14. package/src/tools/AudioPlayer/useAudioHotkeys.ts +142 -0
  15. package/src/tools/AudioPlayer/useAudioVisualization.tsx +195 -0
  16. package/src/tools/ImageViewer/ImageViewer.tsx +416 -0
  17. package/src/tools/ImageViewer/README.md +161 -0
  18. package/src/tools/ImageViewer/index.ts +16 -0
  19. package/src/tools/VideoPlayer/README.md +196 -187
  20. package/src/tools/VideoPlayer/VideoErrorFallback.tsx +174 -0
  21. package/src/tools/VideoPlayer/VideoPlayer.tsx +189 -218
  22. package/src/tools/VideoPlayer/VideoPlayerContext.tsx +125 -0
  23. package/src/tools/VideoPlayer/index.ts +59 -7
  24. package/src/tools/VideoPlayer/providers/NativeProvider.tsx +206 -0
  25. package/src/tools/VideoPlayer/providers/StreamProvider.tsx +311 -0
  26. package/src/tools/VideoPlayer/providers/VidstackProvider.tsx +254 -0
  27. package/src/tools/VideoPlayer/providers/index.ts +8 -0
  28. package/src/tools/VideoPlayer/types.ts +320 -71
  29. package/src/tools/index.ts +82 -4
  30. 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.64",
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.64",
62
- "@djangocfg/ui-core": "^2.1.64",
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.64",
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
- autoplay={media.autoplay}
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;