@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.
@@ -0,0 +1,267 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useProgressiveWaveform - Hook for progressive audio waveform loading
5
+ *
6
+ * Fetches audio in chunks, decodes progressively, and extracts peaks.
7
+ * Provides loading state and partial waveform data as it loads.
8
+ */
9
+
10
+ import { useState, useEffect, useRef, useCallback } from 'react';
11
+ import { extractPeaks, mergePeaks } from './peaks';
12
+ import type { LoadedRange, DecoderOptions } from './types';
13
+
14
+ // =============================================================================
15
+ // CONSTANTS
16
+ // =============================================================================
17
+
18
+ const DEFAULT_CHUNK_SIZE = 512 * 1024; // 512KB
19
+ const DEFAULT_PEAKS_PER_SECOND = 50;
20
+ const MAX_PARALLEL_FETCHES = 2;
21
+
22
+ // =============================================================================
23
+ // TYPES
24
+ // =============================================================================
25
+
26
+ interface UseProgressiveWaveformOptions extends DecoderOptions {
27
+ /** Audio source URL */
28
+ url: string;
29
+ /** Known duration (from metadata) - improves initial render */
30
+ duration?: number;
31
+ /** Enabled */
32
+ enabled?: boolean;
33
+ }
34
+
35
+ interface UseProgressiveWaveformReturn {
36
+ /** Accumulated peaks (0-1 normalized) */
37
+ peaks: number[];
38
+ /** Loading progress (0-100) */
39
+ loadedPercent: number;
40
+ /** Is currently loading */
41
+ isLoading: boolean;
42
+ /** Is complete */
43
+ isComplete: boolean;
44
+ /** Error if any */
45
+ error: Error | null;
46
+ /** Loaded ranges (for visual indication) */
47
+ loadedRanges: LoadedRange[];
48
+ /** Detected duration */
49
+ detectedDuration: number;
50
+ /** Retry loading */
51
+ retry: () => void;
52
+ }
53
+
54
+ // =============================================================================
55
+ // AUDIO CONTEXT SINGLETON
56
+ // =============================================================================
57
+
58
+ let audioContextInstance: AudioContext | null = null;
59
+
60
+ function getAudioContext(): AudioContext {
61
+ if (!audioContextInstance) {
62
+ audioContextInstance = new (window.AudioContext || (window as any).webkitAudioContext)();
63
+ }
64
+ return audioContextInstance;
65
+ }
66
+
67
+ // =============================================================================
68
+ // HOOK
69
+ // =============================================================================
70
+
71
+ export function useProgressiveWaveform(
72
+ options: UseProgressiveWaveformOptions
73
+ ): UseProgressiveWaveformReturn {
74
+ const {
75
+ url,
76
+ duration: knownDuration,
77
+ enabled = true,
78
+ chunkSize = DEFAULT_CHUNK_SIZE,
79
+ peaksPerSecond = DEFAULT_PEAKS_PER_SECOND,
80
+ parallelFetches = MAX_PARALLEL_FETCHES,
81
+ } = options;
82
+
83
+ // State
84
+ const [peaks, setPeaks] = useState<number[]>([]);
85
+ const [loadedPercent, setLoadedPercent] = useState(0);
86
+ const [isLoading, setIsLoading] = useState(false);
87
+ const [isComplete, setIsComplete] = useState(false);
88
+ const [error, setError] = useState<Error | null>(null);
89
+ const [loadedRanges, setLoadedRanges] = useState<LoadedRange[]>([]);
90
+ const [detectedDuration, setDetectedDuration] = useState(knownDuration || 0);
91
+
92
+ // Refs
93
+ const abortControllerRef = useRef<AbortController | null>(null);
94
+ const accumulatedDataRef = useRef<ArrayBuffer[]>([]);
95
+ const totalSizeRef = useRef(0);
96
+ const loadedSizeRef = useRef(0);
97
+ const retryCountRef = useRef(0);
98
+
99
+ // ==========================================================================
100
+ // DECODE ACCUMULATED DATA
101
+ // ==========================================================================
102
+
103
+ const decodeAccumulated = useCallback(async () => {
104
+ const chunks = accumulatedDataRef.current;
105
+ if (chunks.length === 0) return;
106
+
107
+ try {
108
+ // Combine all chunks
109
+ const totalLength = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
110
+ const combined = new Uint8Array(totalLength);
111
+ let offset = 0;
112
+ for (const chunk of chunks) {
113
+ combined.set(new Uint8Array(chunk), offset);
114
+ offset += chunk.byteLength;
115
+ }
116
+
117
+ // Decode
118
+ const audioContext = getAudioContext();
119
+ const audioBuffer = await audioContext.decodeAudioData(combined.buffer.slice(0));
120
+
121
+ // Extract peaks
122
+ const targetPeaks = Math.ceil(audioBuffer.duration * peaksPerSecond);
123
+ const newPeaks = extractPeaks(audioBuffer, { length: targetPeaks });
124
+
125
+ setPeaks(newPeaks);
126
+ setDetectedDuration(audioBuffer.duration);
127
+ } catch (err) {
128
+ // Decoding failed - might need more data
129
+ // This is expected for partial MP3 data
130
+ console.debug('Decode attempt failed, waiting for more data');
131
+ }
132
+ }, [peaksPerSecond]);
133
+
134
+ // ==========================================================================
135
+ // FETCH LOGIC
136
+ // ==========================================================================
137
+
138
+ const loadWaveform = useCallback(async () => {
139
+ if (!url || !enabled) return;
140
+
141
+ // Abort previous
142
+ abortControllerRef.current?.abort();
143
+ abortControllerRef.current = new AbortController();
144
+ const { signal } = abortControllerRef.current;
145
+
146
+ // Reset state
147
+ setIsLoading(true);
148
+ setIsComplete(false);
149
+ setError(null);
150
+ setPeaks([]);
151
+ setLoadedRanges([]);
152
+ setLoadedPercent(0);
153
+ accumulatedDataRef.current = [];
154
+ loadedSizeRef.current = 0;
155
+
156
+ try {
157
+ // Get file size with HEAD request
158
+ const headResponse = await fetch(url, { method: 'HEAD', signal });
159
+ const contentLength = headResponse.headers.get('content-length');
160
+ const totalSize = contentLength ? parseInt(contentLength, 10) : 0;
161
+ totalSizeRef.current = totalSize;
162
+
163
+ if (totalSize === 0) {
164
+ // Fallback: fetch entire file
165
+ const response = await fetch(url, { signal });
166
+ const data = await response.arrayBuffer();
167
+ accumulatedDataRef.current = [data];
168
+ await decodeAccumulated();
169
+ setIsComplete(true);
170
+ setLoadedPercent(100);
171
+ setIsLoading(false);
172
+ return;
173
+ }
174
+
175
+ // Fetch in chunks
176
+ let offset = 0;
177
+ const ranges: LoadedRange[] = [];
178
+
179
+ while (offset < totalSize) {
180
+ if (signal.aborted) return;
181
+
182
+ const end = Math.min(offset + chunkSize - 1, totalSize - 1);
183
+
184
+ const response = await fetch(url, {
185
+ headers: { Range: `bytes=${offset}-${end}` },
186
+ signal,
187
+ });
188
+
189
+ if (!response.ok && response.status !== 206) {
190
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
191
+ }
192
+
193
+ const chunk = await response.arrayBuffer();
194
+ accumulatedDataRef.current.push(chunk);
195
+ loadedSizeRef.current += chunk.byteLength;
196
+
197
+ // Update progress
198
+ const progress = (loadedSizeRef.current / totalSize) * 100;
199
+ setLoadedPercent(progress);
200
+
201
+ // Update ranges
202
+ const rangeStart = offset / totalSize;
203
+ const rangeEnd = (offset + chunk.byteLength) / totalSize;
204
+ ranges.push({ start: rangeStart, end: rangeEnd });
205
+ setLoadedRanges([...ranges]);
206
+
207
+ // Try to decode periodically
208
+ if (accumulatedDataRef.current.length % 2 === 0 || offset + chunkSize >= totalSize) {
209
+ await decodeAccumulated();
210
+ }
211
+
212
+ offset += chunkSize;
213
+ }
214
+
215
+ // Final decode
216
+ await decodeAccumulated();
217
+ setIsComplete(true);
218
+ } catch (err) {
219
+ if ((err as Error).name === 'AbortError') return;
220
+
221
+ const error = err instanceof Error ? err : new Error('Loading failed');
222
+ setError(error);
223
+ console.error('Waveform loading error:', error);
224
+ } finally {
225
+ setIsLoading(false);
226
+ }
227
+ }, [url, enabled, chunkSize, decodeAccumulated]);
228
+
229
+ // ==========================================================================
230
+ // RETRY
231
+ // ==========================================================================
232
+
233
+ const retry = useCallback(() => {
234
+ retryCountRef.current += 1;
235
+ loadWaveform();
236
+ }, [loadWaveform]);
237
+
238
+ // ==========================================================================
239
+ // EFFECTS
240
+ // ==========================================================================
241
+
242
+ useEffect(() => {
243
+ loadWaveform();
244
+
245
+ return () => {
246
+ abortControllerRef.current?.abort();
247
+ };
248
+ }, [loadWaveform]);
249
+
250
+ // Update duration from prop
251
+ useEffect(() => {
252
+ if (knownDuration && knownDuration > 0 && detectedDuration === 0) {
253
+ setDetectedDuration(knownDuration);
254
+ }
255
+ }, [knownDuration, detectedDuration]);
256
+
257
+ return {
258
+ peaks,
259
+ loadedPercent,
260
+ isLoading,
261
+ isComplete,
262
+ error,
263
+ loadedRanges,
264
+ detectedDuration,
265
+ retry,
266
+ };
267
+ }
@@ -17,19 +17,14 @@ import { MediaPlayer, MediaProvider, Poster } from '@vidstack/react';
17
17
  import { defaultLayoutIcons, DefaultVideoLayout } from '@vidstack/react/player/layouts/default';
18
18
  import { useVideoCache } from '../../../stores/mediaCache';
19
19
 
20
- import type { MediaPlayerInstance } from '@vidstack/react';
20
+ import type { MediaPlayerInstance, PlayerSrc } from '@vidstack/react';
21
21
  import type { VidstackProviderProps, VideoPlayerRef, ErrorFallbackProps } from '../types';
22
22
 
23
- /**
24
- * Vidstack source type (string or object with type hint)
25
- */
26
- type VidstackSrc = string | { src: string; type: string };
27
-
28
23
  /**
29
24
  * Convert source to Vidstack-compatible format
30
25
  * Returns object with explicit type for HLS/DASH to ensure proper loader selection
31
26
  */
32
- function getVidstackSrc(source: VidstackProviderProps['source']): VidstackSrc {
27
+ function getVidstackSrc(source: VidstackProviderProps['source']): PlayerSrc {
33
28
  switch (source.type) {
34
29
  case 'youtube':
35
30
  return `youtube/${source.id}`;
@@ -37,9 +32,9 @@ function getVidstackSrc(source: VidstackProviderProps['source']): VidstackSrc {
37
32
  return `vimeo/${source.id}`;
38
33
  case 'hls':
39
34
  // Explicit type needed because URL may have query params that hide .m3u8 extension
40
- return { src: source.url, type: 'application/x-mpegurl' };
35
+ return { src: source.url, type: 'application/x-mpegurl' } as PlayerSrc;
41
36
  case 'dash':
42
- return { src: source.url, type: 'application/dash+xml' };
37
+ return { src: source.url, type: 'application/dash+xml' } as PlayerSrc;
43
38
  case 'url':
44
39
  return source.url;
45
40
  default:
@@ -101,6 +101,15 @@ export {
101
101
  VARIANT_INFO,
102
102
  INTENSITY_INFO,
103
103
  COLOR_SCHEME_INFO,
104
+ // Progressive Audio Player (streaming-friendly)
105
+ ProgressiveAudioPlayer,
106
+ WaveformCanvas,
107
+ useProgressiveAudioElement,
108
+ useProgressiveWaveform,
109
+ extractPeaks,
110
+ mergePeaks,
111
+ resamplePeaks,
112
+ smoothPeaks,
104
113
  } from './AudioPlayer';
105
114
  export type {
106
115
  SimpleAudioPlayerProps,
@@ -120,6 +129,13 @@ export type {
120
129
  AudioHotkeyOptions,
121
130
  ShortcutItem,
122
131
  ShortcutGroup,
132
+ // Progressive types
133
+ ProgressiveAudioPlayerProps,
134
+ WaveformStyle,
135
+ WaveformData,
136
+ LoadedRange,
137
+ ProgressiveAudioState,
138
+ ProgressiveAudioControls,
123
139
  } from './AudioPlayer';
124
140
 
125
141
  // Export ImageViewer