@djangocfg/ui-nextjs 2.1.84 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-nextjs",
3
- "version": "2.1.84",
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.84",
62
- "@djangocfg/ui-core": "^2.1.84",
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.84",
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",
@@ -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 poster URLs
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
 
@@ -110,22 +110,24 @@ export function AudioReactiveCover({
110
110
  transition: 'transform 0.1s ease-out',
111
111
  }}
112
112
  >
113
- {/* Effect layers */}
114
- {glowData && (
115
- <GlowEffect data={glowData} colors={colors} isPlaying={isPlaying} />
116
- )}
117
-
118
- {orbsData && (
119
- <OrbsEffect orbs={orbsData} blur={effectConfig.blur} isPlaying={isPlaying} />
120
- )}
121
-
122
- {spotlightData && (
123
- <SpotlightEffect data={spotlightData} colors={colors} blur={effectConfig.blur} isPlaying={isPlaying} />
124
- )}
125
-
126
- {meshData && (
127
- <MeshEffect gradients={meshData} blur={effectConfig.blur} isPlaying={isPlaying} />
128
- )}
113
+ {/* Effect layers container - under cover, non-interactive */}
114
+ <div className="absolute inset-0 z-0 pointer-events-none overflow-visible">
115
+ {glowData && (
116
+ <GlowEffect data={glowData} colors={colors} isPlaying={isPlaying} />
117
+ )}
118
+
119
+ {orbsData && (
120
+ <OrbsEffect orbs={orbsData} blur={effectConfig.blur} isPlaying={isPlaying} />
121
+ )}
122
+
123
+ {spotlightData && (
124
+ <SpotlightEffect data={spotlightData} colors={colors} blur={effectConfig.blur} isPlaying={isPlaying} />
125
+ )}
126
+
127
+ {meshData && (
128
+ <MeshEffect gradients={meshData} blur={effectConfig.blur} isPlaying={isPlaying} />
129
+ )}
130
+ </div>
129
131
 
130
132
  {/* Content (cover art) */}
131
133
  <div
@@ -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
- const { audioRef, state, controls, webAudio } = useHybridAudio(options);
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-visualization-settings';
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'];
@@ -7,3 +7,6 @@ export type {
7
7
  UseVideoPositionCacheOptions,
8
8
  UseVideoPositionCacheReturn,
9
9
  } from './useVideoPositionCache';
10
+
11
+ export { useVideoPlayerSettings } from './useVideoPlayerSettings';
12
+ export type { VideoPlayerSettingsReturn } from './useVideoPlayerSettings';
@@ -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;