@djangocfg/ui-nextjs 2.1.81 → 2.1.83

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 (36) hide show
  1. package/package.json +4 -4
  2. package/src/tools/AudioPlayer/@refactoring3/00-IMPLEMENTATION-ROADMAP.md +1146 -0
  3. package/src/tools/AudioPlayer/@refactoring3/01-WAVESURFER-STREAMING-ANALYSIS.md +611 -0
  4. package/src/tools/AudioPlayer/@refactoring3/02-MEDIA-VIEWER-ANALYSIS.md +560 -0
  5. package/src/tools/AudioPlayer/@refactoring3/03-HYBRID-ARCHITECTURE-PROPOSAL.md +769 -0
  6. package/src/tools/AudioPlayer/@refactoring3/04-CRACKLING-ISSUE-DIAGNOSIS.md +373 -0
  7. package/src/tools/AudioPlayer/README.md +177 -205
  8. package/src/tools/AudioPlayer/components/AudioPlayer.tsx +9 -4
  9. package/src/tools/AudioPlayer/components/HybridAudioPlayer.tsx +251 -0
  10. package/src/tools/AudioPlayer/components/HybridSimplePlayer.tsx +291 -0
  11. package/src/tools/AudioPlayer/components/HybridWaveform.tsx +279 -0
  12. package/src/tools/AudioPlayer/components/SimpleAudioPlayer.tsx +16 -26
  13. package/src/tools/AudioPlayer/components/index.ts +6 -1
  14. package/src/tools/AudioPlayer/context/AudioProvider.tsx +16 -8
  15. package/src/tools/AudioPlayer/context/HybridAudioProvider.tsx +121 -0
  16. package/src/tools/AudioPlayer/context/index.ts +14 -2
  17. package/src/tools/AudioPlayer/hooks/index.ts +11 -0
  18. package/src/tools/AudioPlayer/hooks/useHybridAudio.ts +387 -0
  19. package/src/tools/AudioPlayer/hooks/useHybridAudioAnalysis.ts +95 -0
  20. package/src/tools/AudioPlayer/hooks/useSharedWebAudio.ts +6 -3
  21. package/src/tools/AudioPlayer/index.ts +31 -0
  22. package/src/tools/AudioPlayer/progressive/ProgressiveAudioPlayer.tsx +8 -0
  23. package/src/tools/ImageViewer/hooks/useImageLoading.ts +33 -9
  24. package/src/tools/VideoPlayer/hooks/useVideoPositionCache.ts +13 -6
  25. package/src/tools/VideoPlayer/providers/StreamProvider.tsx +38 -22
  26. package/src/tools/index.ts +22 -0
  27. package/src/tools/AudioPlayer/@refactoring/00-PLAN.md +0 -148
  28. package/src/tools/AudioPlayer/@refactoring/01-TYPES.md +0 -301
  29. package/src/tools/AudioPlayer/@refactoring/02-HOOKS.md +0 -281
  30. package/src/tools/AudioPlayer/@refactoring/03-CONTEXT.md +0 -328
  31. package/src/tools/AudioPlayer/@refactoring/04-COMPONENTS.md +0 -251
  32. package/src/tools/AudioPlayer/@refactoring/05-EFFECTS.md +0 -427
  33. package/src/tools/AudioPlayer/@refactoring/06-UTILS-AND-INDEX.md +0 -193
  34. package/src/tools/AudioPlayer/@refactoring/07-EXECUTION-CHECKLIST.md +0 -146
  35. package/src/tools/AudioPlayer/@refactoring2/ISSUE_ANALYSIS.md +0 -187
  36. package/src/tools/AudioPlayer/@refactoring2/PLAN.md +0 -372
@@ -0,0 +1,387 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useHybridAudio - Hybrid audio hook combining HTML5 audio + Web Audio API
5
+ *
6
+ * Uses native HTML5 <audio> for playback (no crackling, native streaming)
7
+ * and Web Audio API only for visualization (AnalyserNode).
8
+ *
9
+ * Audio routing:
10
+ * source -> destination (single path for playback)
11
+ * source -> analyser (parallel path for visualization only, no output)
12
+ */
13
+
14
+ import { useRef, useState, useCallback, useEffect } from 'react';
15
+
16
+ // =============================================================================
17
+ // TYPES
18
+ // =============================================================================
19
+
20
+ export interface UseHybridAudioOptions {
21
+ src: string;
22
+ autoPlay?: boolean;
23
+ initialVolume?: number;
24
+ loop?: boolean;
25
+ crossOrigin?: 'anonymous' | 'use-credentials';
26
+ onPlay?: () => void;
27
+ onPause?: () => void;
28
+ onEnded?: () => void;
29
+ onTimeUpdate?: (time: number) => void;
30
+ onError?: (error: Error) => void;
31
+ onReady?: () => void;
32
+ }
33
+
34
+ export interface HybridAudioState {
35
+ isReady: boolean;
36
+ isPlaying: boolean;
37
+ currentTime: number;
38
+ duration: number;
39
+ volume: number;
40
+ isMuted: boolean;
41
+ isLooping: boolean;
42
+ buffered: TimeRanges | null;
43
+ error: Error | null;
44
+ }
45
+
46
+ export interface HybridAudioControls {
47
+ play: () => Promise<void>;
48
+ pause: () => void;
49
+ togglePlay: () => void;
50
+ seek: (time: number) => void;
51
+ seekTo: (progress: number) => void;
52
+ skip: (seconds: number) => void;
53
+ setVolume: (vol: number) => void;
54
+ toggleMute: () => void;
55
+ toggleLoop: () => void;
56
+ setLoop: (enabled: boolean) => void;
57
+ restart: () => void;
58
+ }
59
+
60
+ export interface HybridWebAudioAPI {
61
+ context: AudioContext | null;
62
+ analyser: AnalyserNode | null;
63
+ sourceNode: MediaElementAudioSourceNode | null;
64
+ }
65
+
66
+ export interface UseHybridAudioReturn {
67
+ audioRef: React.RefObject<HTMLAudioElement | null>;
68
+ state: HybridAudioState;
69
+ controls: HybridAudioControls;
70
+ webAudio: HybridWebAudioAPI;
71
+ }
72
+
73
+ // =============================================================================
74
+ // HOOK
75
+ // =============================================================================
76
+
77
+ export function useHybridAudio(options: UseHybridAudioOptions): UseHybridAudioReturn {
78
+ const {
79
+ src,
80
+ autoPlay = false,
81
+ initialVolume = 1,
82
+ loop = false,
83
+ crossOrigin = 'anonymous',
84
+ onPlay,
85
+ onPause,
86
+ onEnded,
87
+ onTimeUpdate,
88
+ onError,
89
+ onReady,
90
+ } = options;
91
+
92
+ // Refs
93
+ const audioRef = useRef<HTMLAudioElement | null>(null);
94
+ const audioContextRef = useRef<AudioContext | null>(null);
95
+ const sourceNodeRef = useRef<MediaElementAudioSourceNode | null>(null);
96
+ const analyserRef = useRef<AnalyserNode | null>(null);
97
+ const connectedElementRef = useRef<HTMLMediaElement | null>(null);
98
+
99
+ // State
100
+ const [state, setState] = useState<HybridAudioState>({
101
+ isReady: false,
102
+ isPlaying: false,
103
+ currentTime: 0,
104
+ duration: 0,
105
+ volume: initialVolume,
106
+ isMuted: false,
107
+ isLooping: loop,
108
+ buffered: null,
109
+ error: null,
110
+ });
111
+
112
+ // Initialize Web Audio for visualization (lazy, on first play)
113
+ const initWebAudio = useCallback(() => {
114
+ const audio = audioRef.current;
115
+ if (!audio) return;
116
+
117
+ // Already connected to this element
118
+ if (connectedElementRef.current === audio && audioContextRef.current) {
119
+ return;
120
+ }
121
+
122
+ try {
123
+ // Create AudioContext if needed
124
+ if (!audioContextRef.current) {
125
+ const AudioContextClass =
126
+ window.AudioContext ||
127
+ (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
128
+ audioContextRef.current = new AudioContextClass();
129
+ }
130
+
131
+ const ctx = audioContextRef.current;
132
+
133
+ // Disconnect old source if any
134
+ if (sourceNodeRef.current) {
135
+ try {
136
+ sourceNodeRef.current.disconnect();
137
+ } catch {
138
+ /* ignore */
139
+ }
140
+ }
141
+
142
+ // Create source from audio element
143
+ const source = ctx.createMediaElementSource(audio);
144
+ sourceNodeRef.current = source;
145
+
146
+ // Create analyser for visualization
147
+ const analyser = ctx.createAnalyser();
148
+ analyser.fftSize = 256;
149
+ analyser.smoothingTimeConstant = 0.85;
150
+ analyserRef.current = analyser;
151
+
152
+ // Audio routing:
153
+ // 1. source -> destination (for playback)
154
+ // 2. source -> analyser (for visualization only - NO output!)
155
+ source.connect(ctx.destination);
156
+ source.connect(analyser);
157
+ // NOTE: analyser does NOT connect to destination - prevents double audio!
158
+
159
+ connectedElementRef.current = audio;
160
+ } catch (error) {
161
+ console.warn('[useHybridAudio] Web Audio init failed:', error);
162
+ }
163
+ }, []);
164
+
165
+ // Resume AudioContext on user interaction
166
+ const resumeAudioContext = useCallback(async () => {
167
+ const ctx = audioContextRef.current;
168
+ if (ctx && ctx.state === 'suspended') {
169
+ await ctx.resume();
170
+ }
171
+ }, []);
172
+
173
+ // Controls
174
+ const play = useCallback(async () => {
175
+ const audio = audioRef.current;
176
+ if (!audio) return;
177
+
178
+ try {
179
+ initWebAudio();
180
+ await resumeAudioContext();
181
+ await audio.play();
182
+ } catch (error) {
183
+ console.error('[useHybridAudio] Play failed:', error);
184
+ onError?.(error as Error);
185
+ }
186
+ }, [initWebAudio, resumeAudioContext, onError]);
187
+
188
+ const pause = useCallback(() => {
189
+ audioRef.current?.pause();
190
+ }, []);
191
+
192
+ const togglePlay = useCallback(() => {
193
+ if (state.isPlaying) {
194
+ pause();
195
+ } else {
196
+ play();
197
+ }
198
+ }, [state.isPlaying, play, pause]);
199
+
200
+ const seek = useCallback(
201
+ (time: number) => {
202
+ const audio = audioRef.current;
203
+ if (audio && isFinite(time)) {
204
+ audio.currentTime = Math.max(0, Math.min(time, state.duration || audio.duration || 0));
205
+ }
206
+ },
207
+ [state.duration]
208
+ );
209
+
210
+ const seekTo = useCallback(
211
+ (progress: number) => {
212
+ const duration = state.duration || audioRef.current?.duration || 0;
213
+ seek(duration * Math.max(0, Math.min(1, progress)));
214
+ },
215
+ [state.duration, seek]
216
+ );
217
+
218
+ const skip = useCallback(
219
+ (seconds: number) => {
220
+ seek(state.currentTime + seconds);
221
+ },
222
+ [state.currentTime, seek]
223
+ );
224
+
225
+ const setVolume = useCallback((vol: number) => {
226
+ const audio = audioRef.current;
227
+ if (audio) {
228
+ const clampedVol = Math.max(0, Math.min(1, vol));
229
+ audio.volume = clampedVol;
230
+ setState((prev) => ({ ...prev, volume: clampedVol }));
231
+ }
232
+ }, []);
233
+
234
+ const toggleMute = useCallback(() => {
235
+ const audio = audioRef.current;
236
+ if (audio) {
237
+ audio.muted = !audio.muted;
238
+ setState((prev) => ({ ...prev, isMuted: audio.muted }));
239
+ }
240
+ }, []);
241
+
242
+ const toggleLoop = useCallback(() => {
243
+ const audio = audioRef.current;
244
+ if (audio) {
245
+ audio.loop = !audio.loop;
246
+ setState((prev) => ({ ...prev, isLooping: audio.loop }));
247
+ }
248
+ }, []);
249
+
250
+ const setLoop = useCallback((enabled: boolean) => {
251
+ const audio = audioRef.current;
252
+ if (audio) {
253
+ audio.loop = enabled;
254
+ setState((prev) => ({ ...prev, isLooping: enabled }));
255
+ }
256
+ }, []);
257
+
258
+ const restart = useCallback(() => {
259
+ seek(0);
260
+ play();
261
+ }, [seek, play]);
262
+
263
+ const controls: HybridAudioControls = {
264
+ play,
265
+ pause,
266
+ togglePlay,
267
+ seek,
268
+ seekTo,
269
+ skip,
270
+ setVolume,
271
+ toggleMute,
272
+ toggleLoop,
273
+ setLoop,
274
+ restart,
275
+ };
276
+
277
+ // Create audio element on mount
278
+ useEffect(() => {
279
+ const audio = document.createElement('audio');
280
+ audio.preload = 'metadata';
281
+ audio.crossOrigin = crossOrigin;
282
+ audio.volume = initialVolume;
283
+ audio.loop = loop;
284
+ audioRef.current = audio;
285
+
286
+ return () => {
287
+ audio.pause();
288
+ audio.src = '';
289
+ if (audioContextRef.current) {
290
+ audioContextRef.current.close().catch(() => {});
291
+ }
292
+ };
293
+ // eslint-disable-next-line react-hooks/exhaustive-deps
294
+ }, []);
295
+
296
+ // Event handlers
297
+ useEffect(() => {
298
+ const audio = audioRef.current;
299
+ if (!audio) return;
300
+
301
+ const handlers = {
302
+ loadedmetadata: () => {
303
+ setState((prev) => ({
304
+ ...prev,
305
+ duration: audio.duration,
306
+ isReady: true,
307
+ }));
308
+ onReady?.();
309
+ },
310
+ canplay: () => {
311
+ setState((prev) => ({ ...prev, isReady: true }));
312
+ if (autoPlay) {
313
+ play();
314
+ }
315
+ },
316
+ play: () => {
317
+ setState((prev) => ({ ...prev, isPlaying: true }));
318
+ onPlay?.();
319
+ },
320
+ pause: () => {
321
+ setState((prev) => ({ ...prev, isPlaying: false }));
322
+ onPause?.();
323
+ },
324
+ ended: () => {
325
+ setState((prev) => ({ ...prev, isPlaying: false }));
326
+ onEnded?.();
327
+ },
328
+ timeupdate: () => {
329
+ setState((prev) => ({ ...prev, currentTime: audio.currentTime }));
330
+ onTimeUpdate?.(audio.currentTime);
331
+ },
332
+ progress: () => {
333
+ setState((prev) => ({ ...prev, buffered: audio.buffered }));
334
+ },
335
+ error: () => {
336
+ const error = new Error(audio.error?.message || 'Audio error');
337
+ setState((prev) => ({ ...prev, error }));
338
+ onError?.(error);
339
+ },
340
+ volumechange: () => {
341
+ setState((prev) => ({
342
+ ...prev,
343
+ volume: audio.volume,
344
+ isMuted: audio.muted,
345
+ }));
346
+ },
347
+ };
348
+
349
+ Object.entries(handlers).forEach(([event, handler]) => {
350
+ audio.addEventListener(event, handler);
351
+ });
352
+
353
+ return () => {
354
+ Object.entries(handlers).forEach(([event, handler]) => {
355
+ audio.removeEventListener(event, handler);
356
+ });
357
+ };
358
+ }, [autoPlay, onPlay, onPause, onEnded, onTimeUpdate, onError, onReady, play]);
359
+
360
+ // Load new source
361
+ useEffect(() => {
362
+ const audio = audioRef.current;
363
+ if (!audio || !src) return;
364
+
365
+ setState((prev) => ({
366
+ ...prev,
367
+ isReady: false,
368
+ currentTime: 0,
369
+ duration: 0,
370
+ error: null,
371
+ }));
372
+
373
+ audio.src = src;
374
+ audio.load();
375
+ }, [src]);
376
+
377
+ return {
378
+ audioRef,
379
+ state,
380
+ controls,
381
+ webAudio: {
382
+ context: audioContextRef.current,
383
+ analyser: analyserRef.current,
384
+ sourceNode: sourceNodeRef.current,
385
+ },
386
+ };
387
+ }
@@ -0,0 +1,95 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useHybridAudioAnalysis - Audio frequency analysis for hybrid player.
5
+ *
6
+ * Simplified version of useAudioAnalysis that works directly with AnalyserNode
7
+ * instead of SharedWebAudioContext.
8
+ */
9
+
10
+ import { useState, useRef, useEffect, useCallback } from 'react';
11
+ import type { AudioLevels } from '../effects';
12
+
13
+ export function useHybridAudioAnalysis(
14
+ analyser: AnalyserNode | null,
15
+ isPlaying: boolean
16
+ ): AudioLevels {
17
+ const [levels, setLevels] = useState<AudioLevels>({ bass: 0, mid: 0, high: 0, overall: 0 });
18
+ const animationRef = useRef<number | null>(null);
19
+ const dataArrayRef = useRef<Uint8Array<ArrayBuffer> | null>(null);
20
+
21
+ const cleanup = useCallback(() => {
22
+ if (animationRef.current) {
23
+ cancelAnimationFrame(animationRef.current);
24
+ animationRef.current = null;
25
+ }
26
+ }, []);
27
+
28
+ // Initialize data array when analyser is available
29
+ useEffect(() => {
30
+ if (analyser && !dataArrayRef.current) {
31
+ dataArrayRef.current = new Uint8Array(analyser.frequencyBinCount) as Uint8Array<ArrayBuffer>;
32
+ }
33
+ }, [analyser]);
34
+
35
+ // Animation loop
36
+ useEffect(() => {
37
+ if (!isPlaying || !analyser || !dataArrayRef.current) {
38
+ cleanup();
39
+ // Smooth fade out when stopped
40
+ setLevels((prev) => ({
41
+ bass: prev.bass * 0.95 < 0.01 ? 0 : prev.bass * 0.95,
42
+ mid: prev.mid * 0.95 < 0.01 ? 0 : prev.mid * 0.95,
43
+ high: prev.high * 0.95 < 0.01 ? 0 : prev.high * 0.95,
44
+ overall: prev.overall * 0.95 < 0.01 ? 0 : prev.overall * 0.95,
45
+ }));
46
+ return;
47
+ }
48
+
49
+ const dataArray = dataArrayRef.current;
50
+
51
+ const animate = () => {
52
+ analyser.getByteFrequencyData(dataArray);
53
+ const binCount = dataArray.length;
54
+
55
+ // Bass (0-15% of frequency range)
56
+ const bassEnd = Math.floor(binCount * 0.15);
57
+ let bassSum = 0;
58
+ for (let i = 0; i < bassEnd; i++) bassSum += dataArray[i];
59
+ const bass = bassSum / bassEnd / 255;
60
+
61
+ // Mids (15-50% of frequency range)
62
+ const midStart = bassEnd;
63
+ const midEnd = Math.floor(binCount * 0.5);
64
+ let midSum = 0;
65
+ for (let i = midStart; i < midEnd; i++) midSum += dataArray[i];
66
+ const mid = midSum / (midEnd - midStart) / 255;
67
+
68
+ // Highs (50-100% of frequency range)
69
+ const highStart = midEnd;
70
+ let highSum = 0;
71
+ for (let i = highStart; i < binCount; i++) highSum += dataArray[i];
72
+ const high = highSum / (binCount - highStart) / 255;
73
+
74
+ // Overall
75
+ let totalSum = 0;
76
+ for (let i = 0; i < binCount; i++) totalSum += dataArray[i];
77
+ const overall = totalSum / binCount / 255;
78
+
79
+ // Smooth with lerp for natural feel
80
+ setLevels((prev) => ({
81
+ bass: prev.bass * 0.7 + bass * 0.3,
82
+ mid: prev.mid * 0.7 + mid * 0.3,
83
+ high: prev.high * 0.7 + high * 0.3,
84
+ overall: prev.overall * 0.7 + overall * 0.3,
85
+ }));
86
+
87
+ animationRef.current = requestAnimationFrame(animate);
88
+ };
89
+
90
+ animationRef.current = requestAnimationFrame(animate);
91
+ return cleanup;
92
+ }, [analyser, isPlaying, cleanup]);
93
+
94
+ return levels;
95
+ }
@@ -42,7 +42,8 @@ export function useSharedWebAudio(audioElement: HTMLMediaElement | null): Shared
42
42
  }
43
43
 
44
44
  sourceRef.current = audioContext.createMediaElementSource(audioElement);
45
- // Connect directly to destination (analysers will be inserted in between)
45
+ // Single audio output path: source -> destination
46
+ // Analysers connect to source in parallel for frequency reading only (no output)
46
47
  sourceRef.current.connect(audioContext.destination);
47
48
  connectedElementRef.current = audioElement;
48
49
  }
@@ -77,9 +78,11 @@ export function useSharedWebAudio(audioElement: HTMLMediaElement | null): Shared
77
78
  analyser.fftSize = options?.fftSize ?? 256;
78
79
  analyser.smoothingTimeConstant = options?.smoothing ?? 0.85;
79
80
 
80
- // Connect: source -> analyser -> destination
81
+ // Connect analyser as passive listener (for frequency analysis only)
82
+ // Audio path: source -> destination (already connected in initAudio)
83
+ // Analysis path: source -> analyser (no output connection needed)
84
+ // NOTE: Do NOT connect analyser to destination - it causes double audio routing and crackling!
81
85
  sourceRef.current.connect(analyser);
82
- analyser.connect(audioContextRef.current.destination);
83
86
 
84
87
  analyserNodesRef.current.add(analyser);
85
88
  return analyser;
@@ -19,6 +19,7 @@
19
19
  // =============================================================================
20
20
 
21
21
  export {
22
+ // WaveSurfer-based (original)
22
23
  AudioPlayer,
23
24
  SimpleAudioPlayer,
24
25
  AudioEqualizer,
@@ -30,6 +31,10 @@ export {
30
31
  OrbsEffect,
31
32
  SpotlightEffect,
32
33
  MeshEffect,
34
+ // Hybrid player (HTML5 audio + Web Audio visualization)
35
+ HybridAudioPlayer,
36
+ HybridSimplePlayer,
37
+ HybridWaveform,
33
38
  } from './components';
34
39
 
35
40
  export type {
@@ -37,6 +42,10 @@ export type {
37
42
  VisualizationToggleProps,
38
43
  AudioReactiveCoverProps,
39
44
  GlowEffectData,
45
+ // Hybrid types
46
+ HybridAudioPlayerProps,
47
+ HybridSimplePlayerProps,
48
+ HybridWaveformProps,
40
49
  } from './components';
41
50
 
42
51
  // =============================================================================
@@ -44,12 +53,25 @@ export type {
44
53
  // =============================================================================
45
54
 
46
55
  export {
56
+ // WaveSurfer-based provider
47
57
  AudioProvider,
48
58
  AudioPlayerContext,
49
59
  useAudio,
50
60
  useAudioControls,
51
61
  useAudioState,
52
62
  useAudioElement,
63
+ // Hybrid provider (HTML5 audio + Web Audio visualization)
64
+ HybridAudioProvider,
65
+ useHybridAudioContext,
66
+ useHybridAudioState,
67
+ useHybridAudioControls,
68
+ useHybridAudioLevels,
69
+ useHybridWebAudio,
70
+ } from './context';
71
+
72
+ export type {
73
+ HybridAudioContextValue,
74
+ HybridAudioProviderProps,
53
75
  } from './context';
54
76
 
55
77
  // =============================================================================
@@ -70,6 +92,9 @@ export {
70
92
  VARIANT_INFO,
71
93
  INTENSITY_INFO,
72
94
  COLOR_SCHEME_INFO,
95
+ // Hybrid hooks
96
+ useHybridAudio,
97
+ useHybridAudioAnalysis,
73
98
  } from './hooks';
74
99
 
75
100
  export type {
@@ -83,6 +108,12 @@ export type {
83
108
  UseVisualizationReturn,
84
109
  UseAudioVisualizationReturn,
85
110
  VisualizationProviderProps,
111
+ // Hybrid types
112
+ UseHybridAudioOptions,
113
+ HybridAudioState,
114
+ HybridAudioControls,
115
+ HybridWebAudioAPI,
116
+ UseHybridAudioReturn,
86
117
  } from './hooks';
87
118
 
88
119
  // =============================================================================
@@ -13,6 +13,14 @@
13
13
  * - Keyboard shortcuts
14
14
  * - Volume control
15
15
  * - Loop mode
16
+ *
17
+ * NOTE: This player does NOT support reactive cover effects.
18
+ * For reactive effects with streaming support, use `HybridSimplePlayer` instead.
19
+ *
20
+ * Use this player when you need:
21
+ * - Progressive waveform that shows the actual audio amplitude shape
22
+ * - Visualization of buffered/loaded ranges
23
+ * - Large file streaming without reactive effects
16
24
  */
17
25
 
18
26
  import { useRef, useCallback } from 'react';
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { useState, useEffect, useRef } from 'react';
8
- import { useImageCache, generateContentKey } from '../../../stores/mediaCache';
8
+ import { useMediaCacheStore, generateContentKey } from '../../../stores/mediaCache';
9
9
  import { createLQIP, MAX_IMAGE_SIZE, WARNING_IMAGE_SIZE, PROGRESSIVE_LOADING_THRESHOLD, imageDebug } from '../utils';
10
10
 
11
11
  // =============================================================================
@@ -50,7 +50,9 @@ export interface UseImageLoadingReturn {
50
50
  export function useImageLoading(options: UseImageLoadingOptions): UseImageLoadingReturn {
51
51
  const { content, mimeType, src: directSrc } = options;
52
52
 
53
- const { getOrCreateBlobUrl, releaseBlobUrl } = useImageCache();
53
+ // Get stable function references from store (not from hook to avoid re-renders)
54
+ const getOrCreateBlobUrl = useMediaCacheStore.getState().getOrCreateBlobUrl;
55
+ const releaseBlobUrl = useMediaCacheStore.getState().releaseBlobUrl;
54
56
 
55
57
  const [src, setSrc] = useState<string | null>(null);
56
58
  const [lqip, setLqip] = useState<string | null>(null);
@@ -58,6 +60,7 @@ export function useImageLoading(options: UseImageLoadingOptions): UseImageLoadin
58
60
  const [error, setError] = useState<string | null>(null);
59
61
 
60
62
  const contentKeyRef = useRef<string | null>(null);
63
+ const isMountedRef = useRef(true);
61
64
 
62
65
  // Calculate size and flags
63
66
  const size = content ? (typeof content === 'string' ? content.length : content.byteLength) : 0;
@@ -65,6 +68,19 @@ export function useImageLoading(options: UseImageLoadingOptions): UseImageLoadin
65
68
  const hasContent = directSrc ? true : size > 0;
66
69
  const useProgressiveLoading = directSrc ? false : size > PROGRESSIVE_LOADING_THRESHOLD;
67
70
 
71
+ // Track unmount for cleanup
72
+ useEffect(() => {
73
+ isMountedRef.current = true;
74
+ return () => {
75
+ isMountedRef.current = false;
76
+ // Release blob URL only on actual unmount
77
+ if (contentKeyRef.current) {
78
+ useMediaCacheStore.getState().releaseBlobUrl(contentKeyRef.current);
79
+ contentKeyRef.current = null;
80
+ }
81
+ };
82
+ }, []);
83
+
68
84
  // Create blob URL with caching and size validation
69
85
  useEffect(() => {
70
86
  // Reset error state
@@ -112,6 +128,12 @@ export function useImageLoading(options: UseImageLoadingOptions): UseImageLoadin
112
128
  const encoder = new TextEncoder();
113
129
  const buffer = encoder.encode(content).buffer;
114
130
  const contentKey = generateContentKey(buffer);
131
+
132
+ // Release previous blob URL if content changed
133
+ if (contentKeyRef.current && contentKeyRef.current !== contentKey) {
134
+ releaseBlobUrl(contentKeyRef.current);
135
+ }
136
+
115
137
  contentKeyRef.current = contentKey;
116
138
  const url = getOrCreateBlobUrl(contentKey, buffer, mimeType || 'image/png');
117
139
  imageDebug.load(url, 'blob');
@@ -122,19 +144,21 @@ export function useImageLoading(options: UseImageLoadingOptions): UseImageLoadin
122
144
 
123
145
  // Handle ArrayBuffer with cached blob URL
124
146
  const contentKey = generateContentKey(content);
147
+
148
+ // Release previous blob URL if content changed
149
+ if (contentKeyRef.current && contentKeyRef.current !== contentKey) {
150
+ releaseBlobUrl(contentKeyRef.current);
151
+ }
152
+
125
153
  contentKeyRef.current = contentKey;
126
154
  const url = getOrCreateBlobUrl(contentKey, content, mimeType || 'image/png');
127
155
  imageDebug.load(url, 'blob');
128
156
  imageDebug.state('loaded', { size, mimeType, contentKey });
129
157
  setSrc(url);
130
158
 
131
- return () => {
132
- if (contentKeyRef.current) {
133
- releaseBlobUrl(contentKeyRef.current);
134
- contentKeyRef.current = null;
135
- }
136
- };
137
- }, [content, mimeType, hasContent, size, directSrc, getOrCreateBlobUrl, releaseBlobUrl]);
159
+ // No cleanup here - cleanup happens in unmount effect above
160
+ // eslint-disable-next-line react-hooks/exhaustive-deps
161
+ }, [content, mimeType, hasContent, size, directSrc]);
138
162
 
139
163
  // Create LQIP for progressive loading
140
164
  useEffect(() => {