@djangocfg/ui-nextjs 2.1.85 → 2.1.87
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/stores/mediaCache.ts +51 -1
- package/src/tools/AudioPlayer/context/HybridAudioProvider.tsx +39 -2
- package/src/tools/AudioPlayer/hooks/useVisualization.tsx +7 -1
- package/src/tools/VideoPlayer/hooks/index.ts +3 -0
- package/src/tools/VideoPlayer/hooks/useVideoPlayerSettings.ts +70 -0
- package/src/tools/VideoPlayer/providers/NativeProvider.tsx +20 -1
- package/src/tools/VideoPlayer/providers/StreamProvider.tsx +22 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-nextjs",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.87",
|
|
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.87",
|
|
62
|
+
"@djangocfg/ui-core": "^2.1.87",
|
|
63
63
|
"@types/react": "^19.1.0",
|
|
64
64
|
"@types/react-dom": "^19.1.0",
|
|
65
65
|
"consola": "^3.4.2",
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
"wavesurfer.js": "^7.12.1"
|
|
111
111
|
},
|
|
112
112
|
"devDependencies": {
|
|
113
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
113
|
+
"@djangocfg/typescript-config": "^2.1.87",
|
|
114
114
|
"@types/node": "^24.7.2",
|
|
115
115
|
"eslint": "^9.37.0",
|
|
116
116
|
"tailwindcss-animate": "1.0.7",
|
package/src/stores/mediaCache.ts
CHANGED
|
@@ -32,6 +32,12 @@ interface EffectConfig {
|
|
|
32
32
|
blur: string;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** Video player settings (persisted) */
|
|
36
|
+
interface VideoPlayerSettings {
|
|
37
|
+
volume: number;
|
|
38
|
+
isLooping: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
35
41
|
// Stream URL TTL (30 seconds - shorter to avoid stale session/token issues)
|
|
36
42
|
const STREAM_URL_TTL = 30 * 1000;
|
|
37
43
|
|
|
@@ -51,6 +57,7 @@ interface MediaCacheState {
|
|
|
51
57
|
videoPosterUrls: Map<string, string>;
|
|
52
58
|
videoPlaybackPositions: Map<string, number>;
|
|
53
59
|
videoMetadata: Map<string, VideoMetadata>;
|
|
60
|
+
videoPlayerSettings: VideoPlayerSettings;
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
interface MediaCacheActions {
|
|
@@ -86,6 +93,8 @@ interface MediaCacheActions {
|
|
|
86
93
|
cacheVideoMetadata: (url: string, meta: VideoMetadata) => void;
|
|
87
94
|
getVideoMetadata: (url: string) => VideoMetadata | null;
|
|
88
95
|
invalidateSession: (sessionId: string) => void;
|
|
96
|
+
getVideoPlayerSettings: () => VideoPlayerSettings;
|
|
97
|
+
saveVideoPlayerSettings: (settings: Partial<VideoPlayerSettings>) => void;
|
|
89
98
|
|
|
90
99
|
// Global
|
|
91
100
|
clearCache: () => void;
|
|
@@ -100,6 +109,11 @@ interface MediaCacheActions {
|
|
|
100
109
|
type MediaCacheStore = MediaCacheState & MediaCacheActions;
|
|
101
110
|
|
|
102
111
|
// Initial state
|
|
112
|
+
const DEFAULT_VIDEO_SETTINGS: VideoPlayerSettings = {
|
|
113
|
+
volume: 1,
|
|
114
|
+
isLooping: false,
|
|
115
|
+
};
|
|
116
|
+
|
|
103
117
|
const initialState: MediaCacheState = {
|
|
104
118
|
blobUrls: new Map(),
|
|
105
119
|
imageDimensions: new Map(),
|
|
@@ -109,6 +123,7 @@ const initialState: MediaCacheState = {
|
|
|
109
123
|
videoPosterUrls: new Map(),
|
|
110
124
|
videoPlaybackPositions: new Map(),
|
|
111
125
|
videoMetadata: new Map(),
|
|
126
|
+
videoPlayerSettings: DEFAULT_VIDEO_SETTINGS,
|
|
112
127
|
};
|
|
113
128
|
|
|
114
129
|
export const useMediaCacheStore = create<MediaCacheStore>()(
|
|
@@ -320,6 +335,18 @@ export const useMediaCacheStore = create<MediaCacheStore>()(
|
|
|
320
335
|
);
|
|
321
336
|
},
|
|
322
337
|
|
|
338
|
+
getVideoPlayerSettings: () => get().videoPlayerSettings,
|
|
339
|
+
|
|
340
|
+
saveVideoPlayerSettings: (settings) => {
|
|
341
|
+
set(
|
|
342
|
+
(state) => ({
|
|
343
|
+
videoPlayerSettings: { ...state.videoPlayerSettings, ...settings },
|
|
344
|
+
}),
|
|
345
|
+
false,
|
|
346
|
+
'video/savePlayerSettings'
|
|
347
|
+
);
|
|
348
|
+
},
|
|
349
|
+
|
|
323
350
|
// ========== Global ==========
|
|
324
351
|
|
|
325
352
|
clearCache: () => {
|
|
@@ -339,7 +366,7 @@ export const useMediaCacheStore = create<MediaCacheStore>()(
|
|
|
339
366
|
}),
|
|
340
367
|
{
|
|
341
368
|
name: 'media-cache-storage',
|
|
342
|
-
// Only persist playback positions and
|
|
369
|
+
// Only persist playback positions, poster URLs, and player settings
|
|
343
370
|
partialize: (state) => ({
|
|
344
371
|
audioPlaybackPositions: Array.from(
|
|
345
372
|
state.audioPlaybackPositions.entries()
|
|
@@ -348,6 +375,7 @@ export const useMediaCacheStore = create<MediaCacheStore>()(
|
|
|
348
375
|
state.videoPlaybackPositions.entries()
|
|
349
376
|
),
|
|
350
377
|
videoPosterUrls: Array.from(state.videoPosterUrls.entries()),
|
|
378
|
+
videoPlayerSettings: state.videoPlayerSettings,
|
|
351
379
|
}),
|
|
352
380
|
// Rehydrate Maps from arrays
|
|
353
381
|
onRehydrateStorage: () => (state) => {
|
|
@@ -356,6 +384,7 @@ export const useMediaCacheStore = create<MediaCacheStore>()(
|
|
|
356
384
|
const persistedAudio = state.audioPlaybackPositions as unknown;
|
|
357
385
|
const persistedVideo = state.videoPlaybackPositions as unknown;
|
|
358
386
|
const persistedPosters = state.videoPosterUrls as unknown;
|
|
387
|
+
const persistedSettings = state.videoPlayerSettings as unknown;
|
|
359
388
|
|
|
360
389
|
state.audioPlaybackPositions = new Map(
|
|
361
390
|
Array.isArray(persistedAudio)
|
|
@@ -372,6 +401,13 @@ export const useMediaCacheStore = create<MediaCacheStore>()(
|
|
|
372
401
|
? (persistedPosters as [string, string][])
|
|
373
402
|
: []
|
|
374
403
|
);
|
|
404
|
+
// Merge persisted settings with defaults (handles missing fields)
|
|
405
|
+
state.videoPlayerSettings = {
|
|
406
|
+
...DEFAULT_VIDEO_SETTINGS,
|
|
407
|
+
...(persistedSettings && typeof persistedSettings === 'object'
|
|
408
|
+
? (persistedSettings as Partial<VideoPlayerSettings>)
|
|
409
|
+
: {}),
|
|
410
|
+
};
|
|
375
411
|
}
|
|
376
412
|
},
|
|
377
413
|
}
|
|
@@ -432,6 +468,20 @@ export const useVideoCache = () =>
|
|
|
432
468
|
cacheVideoMetadata: state.cacheVideoMetadata,
|
|
433
469
|
getVideoMetadata: state.getVideoMetadata,
|
|
434
470
|
invalidateSession: state.invalidateSession,
|
|
471
|
+
getVideoPlayerSettings: state.getVideoPlayerSettings,
|
|
472
|
+
saveVideoPlayerSettings: state.saveVideoPlayerSettings,
|
|
473
|
+
}))
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Hook for video player settings only
|
|
478
|
+
* Returns current settings and save function
|
|
479
|
+
*/
|
|
480
|
+
export const useVideoPlayerSettings = () =>
|
|
481
|
+
useMediaCacheStore(
|
|
482
|
+
useShallow((state) => ({
|
|
483
|
+
settings: state.videoPlayerSettings,
|
|
484
|
+
saveSettings: state.saveVideoPlayerSettings,
|
|
435
485
|
}))
|
|
436
486
|
);
|
|
437
487
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Uses native HTML5 audio for playback with Web Audio API for visualization only.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
|
10
|
+
import { createContext, useContext, useMemo, useEffect, useRef, type ReactNode } from 'react';
|
|
11
11
|
import {
|
|
12
12
|
useHybridAudio,
|
|
13
13
|
type UseHybridAudioOptions,
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type HybridWebAudioAPI,
|
|
17
17
|
} from '../hooks/useHybridAudio';
|
|
18
18
|
import { useHybridAudioAnalysis } from '../hooks/useHybridAudioAnalysis';
|
|
19
|
+
import { useVisualization } from '../hooks/useVisualization';
|
|
19
20
|
import type { AudioLevels } from '../effects';
|
|
20
21
|
|
|
21
22
|
// =============================================================================
|
|
@@ -54,7 +55,43 @@ export interface HybridAudioProviderProps extends UseHybridAudioOptions {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
export function HybridAudioProvider({ children, ...options }: HybridAudioProviderProps) {
|
|
57
|
-
|
|
58
|
+
// Load persisted settings (shared with visualization)
|
|
59
|
+
const { settings: savedSettings, setSetting } = useVisualization();
|
|
60
|
+
|
|
61
|
+
// Use saved settings as initial values
|
|
62
|
+
const effectiveOptions = {
|
|
63
|
+
...options,
|
|
64
|
+
initialVolume: savedSettings.volume,
|
|
65
|
+
loop: savedSettings.isLooping,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const { audioRef, state, controls, webAudio } = useHybridAudio(effectiveOptions);
|
|
69
|
+
|
|
70
|
+
// Track if we've applied initial settings
|
|
71
|
+
const hasAppliedInitialSettings = useRef(false);
|
|
72
|
+
|
|
73
|
+
// Apply saved settings after audio is ready (for hydration timing)
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!state.isReady || hasAppliedInitialSettings.current) return;
|
|
76
|
+
hasAppliedInitialSettings.current = true;
|
|
77
|
+
|
|
78
|
+
// Apply saved volume and loop settings
|
|
79
|
+
controls.setVolume(savedSettings.volume);
|
|
80
|
+
controls.setLoop(savedSettings.isLooping);
|
|
81
|
+
}, [state.isReady, savedSettings, controls]);
|
|
82
|
+
|
|
83
|
+
// Persist settings when they change
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!state.isReady) return;
|
|
86
|
+
|
|
87
|
+
// Only save if values actually changed
|
|
88
|
+
if (state.volume !== savedSettings.volume) {
|
|
89
|
+
setSetting('volume', state.volume);
|
|
90
|
+
}
|
|
91
|
+
if (state.isLooping !== savedSettings.isLooping) {
|
|
92
|
+
setSetting('isLooping', state.isLooping);
|
|
93
|
+
}
|
|
94
|
+
}, [state.isReady, state.volume, state.isLooping, savedSettings, setSetting]);
|
|
58
95
|
|
|
59
96
|
// Audio analysis for reactive effects
|
|
60
97
|
const audioLevels = useHybridAudioAnalysis(webAudio.analyser, state.isPlaying);
|
|
@@ -27,6 +27,10 @@ export interface VisualizationSettings {
|
|
|
27
27
|
intensity: VisualizationIntensity;
|
|
28
28
|
/** Color scheme */
|
|
29
29
|
colorScheme: VisualizationColorScheme;
|
|
30
|
+
/** Playback volume (0-1) */
|
|
31
|
+
volume: number;
|
|
32
|
+
/** Loop playback */
|
|
33
|
+
isLooping: boolean;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
export interface UseVisualizationReturn {
|
|
@@ -56,13 +60,15 @@ export type UseAudioVisualizationReturn = UseVisualizationReturn;
|
|
|
56
60
|
// CONSTANTS
|
|
57
61
|
// =============================================================================
|
|
58
62
|
|
|
59
|
-
const STORAGE_KEY = 'audio-
|
|
63
|
+
const STORAGE_KEY = 'audio-player-settings';
|
|
60
64
|
|
|
61
65
|
const DEFAULT_SETTINGS: VisualizationSettings = {
|
|
62
66
|
enabled: true,
|
|
63
67
|
variant: 'spotlight',
|
|
64
68
|
intensity: 'medium',
|
|
65
69
|
colorScheme: 'primary',
|
|
70
|
+
volume: 1,
|
|
71
|
+
isLooping: false,
|
|
66
72
|
};
|
|
67
73
|
|
|
68
74
|
const VARIANTS: VisualizationVariant[] = ['spotlight', 'glow', 'orbs', 'mesh', 'none'];
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useVideoPlayerSettings - Hook for persisted video player settings
|
|
5
|
+
*
|
|
6
|
+
* Provides volume and loop settings that persist in localStorage.
|
|
7
|
+
* Use this hook in video player providers to apply saved settings.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* const { settings, updateVolume, updateLoop } = useVideoPlayerSettings();
|
|
11
|
+
*
|
|
12
|
+
* // Apply to video element
|
|
13
|
+
* videoRef.current.volume = settings.volume;
|
|
14
|
+
* videoRef.current.loop = settings.isLooping;
|
|
15
|
+
*
|
|
16
|
+
* // Save when user changes
|
|
17
|
+
* const handleVolumeChange = (vol: number) => {
|
|
18
|
+
* videoRef.current.volume = vol;
|
|
19
|
+
* updateVolume(vol);
|
|
20
|
+
* };
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { useCallback } from 'react';
|
|
24
|
+
import { useVideoPlayerSettings as useSettings } from '../../../stores/mediaCache';
|
|
25
|
+
|
|
26
|
+
export interface VideoPlayerSettingsReturn {
|
|
27
|
+
/** Current settings */
|
|
28
|
+
settings: {
|
|
29
|
+
volume: number;
|
|
30
|
+
isLooping: boolean;
|
|
31
|
+
};
|
|
32
|
+
/** Update volume (0-1) */
|
|
33
|
+
updateVolume: (volume: number) => void;
|
|
34
|
+
/** Update loop setting */
|
|
35
|
+
updateLoop: (isLooping: boolean) => void;
|
|
36
|
+
/** Update multiple settings at once */
|
|
37
|
+
updateSettings: (settings: { volume?: number; isLooping?: boolean }) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function useVideoPlayerSettings(): VideoPlayerSettingsReturn {
|
|
41
|
+
const { settings, saveSettings } = useSettings();
|
|
42
|
+
|
|
43
|
+
const updateVolume = useCallback(
|
|
44
|
+
(volume: number) => {
|
|
45
|
+
saveSettings({ volume: Math.max(0, Math.min(1, volume)) });
|
|
46
|
+
},
|
|
47
|
+
[saveSettings]
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const updateLoop = useCallback(
|
|
51
|
+
(isLooping: boolean) => {
|
|
52
|
+
saveSettings({ isLooping });
|
|
53
|
+
},
|
|
54
|
+
[saveSettings]
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const updateSettings = useCallback(
|
|
58
|
+
(newSettings: { volume?: number; isLooping?: boolean }) => {
|
|
59
|
+
saveSettings(newSettings);
|
|
60
|
+
},
|
|
61
|
+
[saveSettings]
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
settings,
|
|
66
|
+
updateVolume,
|
|
67
|
+
updateLoop,
|
|
68
|
+
updateSettings,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -9,6 +9,7 @@ import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } f
|
|
|
9
9
|
|
|
10
10
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
11
11
|
import { Preloader, AspectRatio } from '@djangocfg/ui-core';
|
|
12
|
+
import { useVideoPlayerSettings } from '../hooks/useVideoPlayerSettings';
|
|
12
13
|
|
|
13
14
|
import type { NativeProviderProps, VideoPlayerRef } from '../types';
|
|
14
15
|
import { videoDebug } from '../utils/debug';
|
|
@@ -56,6 +57,9 @@ export const NativeProvider = forwardRef<VideoPlayerRef, NativeProviderProps>(
|
|
|
56
57
|
const [isLoading, setIsLoading] = useState(showPreloader);
|
|
57
58
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
58
59
|
|
|
60
|
+
// Persisted player settings
|
|
61
|
+
const { settings: savedSettings, updateVolume } = useVideoPlayerSettings();
|
|
62
|
+
|
|
59
63
|
const videoUrl = getVideoUrl(source);
|
|
60
64
|
|
|
61
65
|
// Debug: Log video source
|
|
@@ -140,6 +144,8 @@ export const NativeProvider = forwardRef<VideoPlayerRef, NativeProviderProps>(
|
|
|
140
144
|
const handleCanPlayDebug = () => {
|
|
141
145
|
videoDebug.state('canplay', { duration: video.duration, buffered: video.buffered.length });
|
|
142
146
|
videoDebug.buffer(video.buffered, video.duration);
|
|
147
|
+
// Apply saved volume
|
|
148
|
+
video.volume = savedSettings.volume;
|
|
143
149
|
};
|
|
144
150
|
|
|
145
151
|
const handleSeeking = () => {
|
|
@@ -176,7 +182,20 @@ export const NativeProvider = forwardRef<VideoPlayerRef, NativeProviderProps>(
|
|
|
176
182
|
video.removeEventListener('waiting', handleWaiting);
|
|
177
183
|
video.removeEventListener('stalled', handleStalled);
|
|
178
184
|
};
|
|
179
|
-
}, []);
|
|
185
|
+
}, [savedSettings.volume]);
|
|
186
|
+
|
|
187
|
+
// Persist volume when user changes it via native controls
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
const video = videoRef.current;
|
|
190
|
+
if (!video) return;
|
|
191
|
+
|
|
192
|
+
const handleVolumeChange = () => {
|
|
193
|
+
updateVolume(video.volume);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
video.addEventListener('volumechange', handleVolumeChange);
|
|
197
|
+
return () => video.removeEventListener('volumechange', handleVolumeChange);
|
|
198
|
+
}, [updateVolume]);
|
|
180
199
|
|
|
181
200
|
const handleContextMenu = (e: React.MouseEvent) => {
|
|
182
201
|
if (disableContextMenu) {
|
|
@@ -15,6 +15,7 @@ import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef,
|
|
|
15
15
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
16
16
|
import { Preloader, AspectRatio } from '@djangocfg/ui-core';
|
|
17
17
|
import { useMediaCacheStore, generateContentKey } from '../../../stores/mediaCache';
|
|
18
|
+
import { useVideoPlayerSettings } from '../hooks/useVideoPlayerSettings';
|
|
18
19
|
|
|
19
20
|
import type { StreamProviderProps, VideoPlayerRef, StreamSource, BlobSource, DataUrlSource, ErrorFallbackProps } from '../types';
|
|
20
21
|
import { videoDebug } from '../utils/debug';
|
|
@@ -84,6 +85,9 @@ export const StreamProvider = forwardRef<VideoPlayerRef, StreamProviderProps>(
|
|
|
84
85
|
const saveVideoPosition = useMediaCacheStore.getState().saveVideoPosition;
|
|
85
86
|
const getVideoPosition = useMediaCacheStore.getState().getVideoPosition;
|
|
86
87
|
|
|
88
|
+
// Persisted player settings
|
|
89
|
+
const { settings: savedSettings, updateVolume } = useVideoPlayerSettings();
|
|
90
|
+
|
|
87
91
|
// Retry function for error fallback
|
|
88
92
|
// Regenerates URL for stream sources to get fresh token/session
|
|
89
93
|
const retry = useCallback(() => {
|
|
@@ -233,12 +237,15 @@ export const StreamProvider = forwardRef<VideoPlayerRef, StreamProviderProps>(
|
|
|
233
237
|
}
|
|
234
238
|
}, [source]);
|
|
235
239
|
|
|
236
|
-
// Restore cached playback position when video is ready
|
|
240
|
+
// Restore cached playback position and settings when video is ready
|
|
237
241
|
const handleCanPlay = useCallback(() => {
|
|
238
242
|
const video = videoRef.current;
|
|
239
243
|
if (video) {
|
|
240
244
|
videoDebug.state('canplay', { duration: video.duration, buffered: video.buffered.length });
|
|
241
245
|
videoDebug.buffer(video.buffered, video.duration);
|
|
246
|
+
|
|
247
|
+
// Apply saved volume (user preference)
|
|
248
|
+
video.volume = savedSettings.volume;
|
|
242
249
|
}
|
|
243
250
|
setIsLoading(false);
|
|
244
251
|
|
|
@@ -258,7 +265,7 @@ export const StreamProvider = forwardRef<VideoPlayerRef, StreamProviderProps>(
|
|
|
258
265
|
|
|
259
266
|
onCanPlay?.();
|
|
260
267
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
261
|
-
}, [getSourceKey, onCanPlay]);
|
|
268
|
+
}, [getSourceKey, onCanPlay, savedSettings.volume]);
|
|
262
269
|
|
|
263
270
|
// Save playback position periodically
|
|
264
271
|
const handleTimeUpdate = useCallback(() => {
|
|
@@ -383,6 +390,19 @@ export const StreamProvider = forwardRef<VideoPlayerRef, StreamProviderProps>(
|
|
|
383
390
|
};
|
|
384
391
|
}, [videoUrl]);
|
|
385
392
|
|
|
393
|
+
// Persist volume when user changes it via native controls
|
|
394
|
+
useEffect(() => {
|
|
395
|
+
const video = videoRef.current;
|
|
396
|
+
if (!video) return;
|
|
397
|
+
|
|
398
|
+
const handleVolumeChange = () => {
|
|
399
|
+
updateVolume(video.volume);
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
video.addEventListener('volumechange', handleVolumeChange);
|
|
403
|
+
return () => video.removeEventListener('volumechange', handleVolumeChange);
|
|
404
|
+
}, [videoUrl, updateVolume]);
|
|
405
|
+
|
|
386
406
|
// Determine if we should use AspectRatio wrapper or fill mode
|
|
387
407
|
const isFillMode = aspectRatio === 'fill';
|
|
388
408
|
const computedAspectRatio = aspectRatio === 'auto' || aspectRatio === 'fill' ? undefined : aspectRatio;
|