@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.
- package/package.json +9 -6
- package/src/blocks/SplitHero/SplitHeroMedia.tsx +2 -1
- package/src/tools/AudioPlayer/AudioEqualizer.tsx +235 -0
- package/src/tools/AudioPlayer/AudioPlayer.tsx +223 -0
- package/src/tools/AudioPlayer/AudioReactiveCover.tsx +389 -0
- package/src/tools/AudioPlayer/AudioShortcutsPopover.tsx +95 -0
- package/src/tools/AudioPlayer/README.md +301 -0
- package/src/tools/AudioPlayer/SimpleAudioPlayer.tsx +275 -0
- package/src/tools/AudioPlayer/VisualizationToggle.tsx +68 -0
- package/src/tools/AudioPlayer/context.tsx +426 -0
- package/src/tools/AudioPlayer/effects/index.ts +412 -0
- package/src/tools/AudioPlayer/index.ts +84 -0
- package/src/tools/AudioPlayer/types.ts +162 -0
- package/src/tools/AudioPlayer/useAudioHotkeys.ts +142 -0
- package/src/tools/AudioPlayer/useAudioVisualization.tsx +195 -0
- package/src/tools/ImageViewer/ImageViewer.tsx +416 -0
- package/src/tools/ImageViewer/README.md +161 -0
- package/src/tools/ImageViewer/index.ts +16 -0
- package/src/tools/VideoPlayer/README.md +196 -187
- package/src/tools/VideoPlayer/VideoErrorFallback.tsx +174 -0
- package/src/tools/VideoPlayer/VideoPlayer.tsx +189 -218
- package/src/tools/VideoPlayer/VideoPlayerContext.tsx +125 -0
- package/src/tools/VideoPlayer/index.ts +59 -7
- package/src/tools/VideoPlayer/providers/NativeProvider.tsx +206 -0
- package/src/tools/VideoPlayer/providers/StreamProvider.tsx +311 -0
- package/src/tools/VideoPlayer/providers/VidstackProvider.tsx +254 -0
- package/src/tools/VideoPlayer/providers/index.ts +8 -0
- package/src/tools/VideoPlayer/types.ts +320 -71
- package/src/tools/index.ts +82 -4
- package/src/tools/VideoPlayer/NativePlayer.tsx +0 -141
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AudioReactiveCover - Album art with audio-reactive animations
|
|
5
|
+
*
|
|
6
|
+
* Uses effects utilities for clean data preparation before render.
|
|
7
|
+
* Click on cover to switch between effect variants.
|
|
8
|
+
*
|
|
9
|
+
* Audio analysis is performed in the AudioProvider context to persist
|
|
10
|
+
* across variant changes (including switching to 'none' and back).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { type ReactNode } from 'react';
|
|
14
|
+
import { cn } from '@djangocfg/ui-nextjs';
|
|
15
|
+
import { useAudioElement } from './context';
|
|
16
|
+
import {
|
|
17
|
+
type EffectVariant,
|
|
18
|
+
type EffectIntensity,
|
|
19
|
+
type EffectColorScheme,
|
|
20
|
+
getEffectConfig,
|
|
21
|
+
prepareEffectColors,
|
|
22
|
+
calculateGlowLayers,
|
|
23
|
+
calculateOrbs,
|
|
24
|
+
calculateMeshGradients,
|
|
25
|
+
calculateSpotlight,
|
|
26
|
+
EFFECT_ANIMATIONS,
|
|
27
|
+
} from './effects';
|
|
28
|
+
|
|
29
|
+
// =============================================================================
|
|
30
|
+
// TYPES
|
|
31
|
+
// =============================================================================
|
|
32
|
+
|
|
33
|
+
export interface AudioReactiveCoverProps {
|
|
34
|
+
children: ReactNode;
|
|
35
|
+
size?: 'sm' | 'md' | 'lg';
|
|
36
|
+
variant?: EffectVariant;
|
|
37
|
+
intensity?: EffectIntensity;
|
|
38
|
+
colorScheme?: EffectColorScheme;
|
|
39
|
+
onClick?: () => void;
|
|
40
|
+
className?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// =============================================================================
|
|
44
|
+
// CONSTANTS
|
|
45
|
+
// =============================================================================
|
|
46
|
+
|
|
47
|
+
const SIZES = {
|
|
48
|
+
sm: { container: 'w-32 h-32', orbBase: 40 },
|
|
49
|
+
md: { container: 'w-40 h-40', orbBase: 50 },
|
|
50
|
+
lg: { container: 'w-48 h-48', orbBase: 60 },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// =============================================================================
|
|
54
|
+
// COMPONENT
|
|
55
|
+
// =============================================================================
|
|
56
|
+
|
|
57
|
+
export function AudioReactiveCover({
|
|
58
|
+
children,
|
|
59
|
+
size = 'lg',
|
|
60
|
+
variant = 'spotlight',
|
|
61
|
+
intensity = 'medium',
|
|
62
|
+
colorScheme = 'primary',
|
|
63
|
+
onClick,
|
|
64
|
+
className,
|
|
65
|
+
}: AudioReactiveCoverProps) {
|
|
66
|
+
// Get audio levels from context (persists across variant changes)
|
|
67
|
+
const { isPlaying, audioLevels: levels } = useAudioElement();
|
|
68
|
+
|
|
69
|
+
// =========================================================================
|
|
70
|
+
// PREPARE DATA BEFORE RENDER
|
|
71
|
+
// =========================================================================
|
|
72
|
+
|
|
73
|
+
const sizeConfig = SIZES[size];
|
|
74
|
+
const effectConfig = getEffectConfig(intensity);
|
|
75
|
+
const { colors, hueShift } = prepareEffectColors(colorScheme, levels);
|
|
76
|
+
|
|
77
|
+
// Calculate scale based on overall level
|
|
78
|
+
const containerScale = 1 + levels.overall * effectConfig.scale;
|
|
79
|
+
|
|
80
|
+
// Prepare effect-specific data - NO memoization for real-time reactivity
|
|
81
|
+
const glowData = variant === 'glow' ? {
|
|
82
|
+
layers: calculateGlowLayers(levels, effectConfig, colors),
|
|
83
|
+
hueShift,
|
|
84
|
+
showPulseRings: levels.bass > 0.5,
|
|
85
|
+
showSparkle: levels.high > 0.4,
|
|
86
|
+
} : null;
|
|
87
|
+
|
|
88
|
+
const orbsData = variant === 'orbs'
|
|
89
|
+
? calculateOrbs(levels, effectConfig, colors, sizeConfig.orbBase)
|
|
90
|
+
: null;
|
|
91
|
+
|
|
92
|
+
const meshData = variant === 'mesh'
|
|
93
|
+
? calculateMeshGradients(levels, effectConfig, colors)
|
|
94
|
+
: null;
|
|
95
|
+
|
|
96
|
+
const spotlightData = variant === 'spotlight'
|
|
97
|
+
? calculateSpotlight(levels, effectConfig, colors, levels.mid * 360)
|
|
98
|
+
: null;
|
|
99
|
+
|
|
100
|
+
// =========================================================================
|
|
101
|
+
// RENDER
|
|
102
|
+
// =========================================================================
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div
|
|
106
|
+
className={cn('relative', sizeConfig.container, className)}
|
|
107
|
+
style={{
|
|
108
|
+
transform: `scale(${containerScale})`,
|
|
109
|
+
transition: 'transform 0.1s ease-out',
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
{/* Effect layers */}
|
|
113
|
+
{glowData && (
|
|
114
|
+
<GlowEffect data={glowData} colors={colors} isPlaying={isPlaying} />
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
{orbsData && (
|
|
118
|
+
<OrbsEffect orbs={orbsData} blur={effectConfig.blur} isPlaying={isPlaying} />
|
|
119
|
+
)}
|
|
120
|
+
|
|
121
|
+
{spotlightData && (
|
|
122
|
+
<SpotlightEffect data={spotlightData} colors={colors} blur={effectConfig.blur} isPlaying={isPlaying} />
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
{meshData && (
|
|
126
|
+
<MeshEffect gradients={meshData} blur={effectConfig.blur} isPlaying={isPlaying} />
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{/* Content (cover art) */}
|
|
130
|
+
<div
|
|
131
|
+
className="relative w-full h-full rounded-lg overflow-hidden shadow-2xl z-10 bg-background cursor-pointer"
|
|
132
|
+
onClick={onClick}
|
|
133
|
+
role={onClick ? 'button' : undefined}
|
|
134
|
+
tabIndex={onClick ? 0 : undefined}
|
|
135
|
+
onKeyDown={onClick ? (e) => e.key === 'Enter' && onClick() : undefined}
|
|
136
|
+
>
|
|
137
|
+
{children}
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
{/* Inject animations once */}
|
|
141
|
+
<style dangerouslySetInnerHTML={{ __html: EFFECT_ANIMATIONS }} />
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// =============================================================================
|
|
147
|
+
// EFFECT COMPONENTS (Pure render, no logic)
|
|
148
|
+
// =============================================================================
|
|
149
|
+
|
|
150
|
+
interface GlowEffectData {
|
|
151
|
+
layers: ReturnType<typeof calculateGlowLayers>;
|
|
152
|
+
hueShift: number;
|
|
153
|
+
showPulseRings: boolean;
|
|
154
|
+
showSparkle: boolean;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function GlowEffect({
|
|
158
|
+
data,
|
|
159
|
+
colors,
|
|
160
|
+
isPlaying
|
|
161
|
+
}: {
|
|
162
|
+
data: GlowEffectData;
|
|
163
|
+
colors: string[];
|
|
164
|
+
isPlaying: boolean;
|
|
165
|
+
}) {
|
|
166
|
+
const { layers, hueShift, showPulseRings, showSparkle } = data;
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<>
|
|
170
|
+
{/* Main glow layers */}
|
|
171
|
+
{layers.map((layer, i) => (
|
|
172
|
+
<div
|
|
173
|
+
key={i}
|
|
174
|
+
className={cn('absolute rounded-2xl -z-10', layer.blur)}
|
|
175
|
+
style={{
|
|
176
|
+
inset: `-${layer.inset}px`,
|
|
177
|
+
background: layer.background,
|
|
178
|
+
opacity: isPlaying ? layer.opacity : 0,
|
|
179
|
+
transform: i < 2 ? `scaleY(${layer.scale})` : `scale(${layer.scale})`,
|
|
180
|
+
animation: isPlaying && layer.animation ? layer.animation : 'none',
|
|
181
|
+
transition: 'opacity 0.3s',
|
|
182
|
+
}}
|
|
183
|
+
/>
|
|
184
|
+
))}
|
|
185
|
+
|
|
186
|
+
{/* Rotating color sweep */}
|
|
187
|
+
<div
|
|
188
|
+
className="absolute rounded-2xl blur-xl overflow-hidden -z-10"
|
|
189
|
+
style={{
|
|
190
|
+
inset: '-75px',
|
|
191
|
+
opacity: isPlaying ? 0.6 : 0,
|
|
192
|
+
transition: 'opacity 0.5s',
|
|
193
|
+
}}
|
|
194
|
+
>
|
|
195
|
+
<div
|
|
196
|
+
className="absolute inset-0"
|
|
197
|
+
style={{
|
|
198
|
+
background: `conic-gradient(
|
|
199
|
+
from ${hueShift}deg at 50% 50%,
|
|
200
|
+
hsl(${colors[0]} / 0.4) 0deg,
|
|
201
|
+
hsl(${colors[1] || colors[0]} / 0.3) 90deg,
|
|
202
|
+
hsl(${colors[2] || colors[0]} / 0.3) 180deg,
|
|
203
|
+
hsl(${colors[3] || colors[0]} / 0.3) 270deg,
|
|
204
|
+
hsl(${colors[0]} / 0.4) 360deg
|
|
205
|
+
)`,
|
|
206
|
+
animation: isPlaying ? 'glow-rotate 6s linear infinite' : 'none',
|
|
207
|
+
}}
|
|
208
|
+
/>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
{/* Pulse rings on bass hits */}
|
|
212
|
+
{showPulseRings && (
|
|
213
|
+
<>
|
|
214
|
+
<div
|
|
215
|
+
className="absolute -inset-6 rounded-xl border-2 animate-ping -z-10"
|
|
216
|
+
style={{
|
|
217
|
+
borderColor: `hsl(${colors[0]} / 0.4)`,
|
|
218
|
+
animationDuration: '1s',
|
|
219
|
+
}}
|
|
220
|
+
/>
|
|
221
|
+
<div
|
|
222
|
+
className="absolute -inset-12 rounded-2xl border animate-ping -z-10"
|
|
223
|
+
style={{
|
|
224
|
+
borderColor: `hsl(${colors[1] || colors[0]} / 0.3)`,
|
|
225
|
+
animationDuration: '1.5s',
|
|
226
|
+
animationDelay: '0.2s',
|
|
227
|
+
}}
|
|
228
|
+
/>
|
|
229
|
+
</>
|
|
230
|
+
)}
|
|
231
|
+
|
|
232
|
+
{/* Sparkle on high frequencies */}
|
|
233
|
+
{showSparkle && (
|
|
234
|
+
<div
|
|
235
|
+
className="absolute -inset-18 rounded-3xl -z-10"
|
|
236
|
+
style={{
|
|
237
|
+
background: `radial-gradient(circle at 50% 30%, hsl(${colors[2] || colors[0]} / 0.5) 0%, transparent 30%)`,
|
|
238
|
+
animation: 'sparkle-move 0.5s ease-out',
|
|
239
|
+
}}
|
|
240
|
+
/>
|
|
241
|
+
)}
|
|
242
|
+
</>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function OrbsEffect({
|
|
247
|
+
orbs,
|
|
248
|
+
blur,
|
|
249
|
+
isPlaying
|
|
250
|
+
}: {
|
|
251
|
+
orbs: ReturnType<typeof calculateOrbs>;
|
|
252
|
+
blur: string;
|
|
253
|
+
isPlaying: boolean;
|
|
254
|
+
}) {
|
|
255
|
+
return (
|
|
256
|
+
<>
|
|
257
|
+
{orbs.map((orb, i) => (
|
|
258
|
+
<div
|
|
259
|
+
key={i}
|
|
260
|
+
className={cn('absolute rounded-full -z-10', blur)}
|
|
261
|
+
style={{
|
|
262
|
+
width: orb.size,
|
|
263
|
+
height: orb.size,
|
|
264
|
+
left: `${orb.x}%`,
|
|
265
|
+
top: `${orb.y}%`,
|
|
266
|
+
background: `radial-gradient(circle at 30% 30%, hsl(${orb.color}) 0%, hsl(${orb.color} / 0.5) 40%, transparent 70%)`,
|
|
267
|
+
opacity: isPlaying ? orb.opacity : 0,
|
|
268
|
+
transform: `translate(-50%, -50%) scale(${orb.scale})`,
|
|
269
|
+
transition: 'all 0.08s ease-out',
|
|
270
|
+
}}
|
|
271
|
+
/>
|
|
272
|
+
))}
|
|
273
|
+
</>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function SpotlightEffect({
|
|
278
|
+
data,
|
|
279
|
+
colors,
|
|
280
|
+
blur,
|
|
281
|
+
isPlaying
|
|
282
|
+
}: {
|
|
283
|
+
data: ReturnType<typeof calculateSpotlight>;
|
|
284
|
+
colors: string[];
|
|
285
|
+
blur: string;
|
|
286
|
+
isPlaying: boolean;
|
|
287
|
+
}) {
|
|
288
|
+
const inset = 'inset' in data ? data.inset : 12;
|
|
289
|
+
const pulseInset = 'pulseInset' in data ? data.pulseInset : 24;
|
|
290
|
+
const ringOpacity = 'ringOpacity' in data ? data.ringOpacity : 0.3;
|
|
291
|
+
const ringScale = 'ringScale' in data ? data.ringScale : 1;
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
<>
|
|
295
|
+
{/* Rotating conic gradient - reactive size */}
|
|
296
|
+
<div
|
|
297
|
+
className={cn('absolute rounded-xl -z-10', blur)}
|
|
298
|
+
style={{
|
|
299
|
+
inset: `-${inset}px`,
|
|
300
|
+
background: `conic-gradient(
|
|
301
|
+
from ${data.rotation}deg,
|
|
302
|
+
hsl(${colors[0]} / ${data.colors[0]?.opacity || 0.5}),
|
|
303
|
+
hsl(${colors[1] || colors[0]} / ${data.colors[1]?.opacity || 0.7}),
|
|
304
|
+
hsl(${colors[2] || colors[0]} / ${data.colors[2]?.opacity || 0.5}),
|
|
305
|
+
hsl(${colors[0]} / ${data.colors[1]?.opacity || 0.7}),
|
|
306
|
+
hsl(${colors[0]} / ${data.colors[0]?.opacity || 0.5})
|
|
307
|
+
)`,
|
|
308
|
+
opacity: isPlaying ? 1 : 0,
|
|
309
|
+
transition: 'all 0.08s ease-out',
|
|
310
|
+
}}
|
|
311
|
+
/>
|
|
312
|
+
|
|
313
|
+
{/* Inner border */}
|
|
314
|
+
<div
|
|
315
|
+
className="absolute -inset-1 rounded-lg bg-background -z-10"
|
|
316
|
+
style={{ opacity: isPlaying ? 1 : 0, transition: 'opacity 0.1s' }}
|
|
317
|
+
/>
|
|
318
|
+
|
|
319
|
+
{/* Bass pulse glow - reactive size */}
|
|
320
|
+
<div
|
|
321
|
+
className={cn('absolute rounded-2xl -z-10', blur)}
|
|
322
|
+
style={{
|
|
323
|
+
inset: `-${pulseInset}px`,
|
|
324
|
+
background: `radial-gradient(circle, hsl(${colors[0]} / 0.7) 0%, hsl(${colors[0]} / 0.3) 50%, transparent 70%)`,
|
|
325
|
+
opacity: isPlaying ? data.pulseOpacity : 0,
|
|
326
|
+
transform: `scale(${data.pulseScale})`,
|
|
327
|
+
transition: 'all 0.08s ease-out',
|
|
328
|
+
}}
|
|
329
|
+
/>
|
|
330
|
+
|
|
331
|
+
{/* Outer ring glow */}
|
|
332
|
+
<div
|
|
333
|
+
className="absolute rounded-3xl -z-10 blur-2xl"
|
|
334
|
+
style={{
|
|
335
|
+
inset: `-${pulseInset + 30}px`,
|
|
336
|
+
background: `radial-gradient(circle, hsl(${colors[1] || colors[0]} / 0.4) 0%, transparent 60%)`,
|
|
337
|
+
opacity: isPlaying ? ringOpacity : 0,
|
|
338
|
+
transform: `scale(${ringScale})`,
|
|
339
|
+
transition: 'all 0.08s ease-out',
|
|
340
|
+
}}
|
|
341
|
+
/>
|
|
342
|
+
</>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function MeshEffect({
|
|
347
|
+
gradients,
|
|
348
|
+
isPlaying
|
|
349
|
+
}: {
|
|
350
|
+
gradients: ReturnType<typeof calculateMeshGradients>;
|
|
351
|
+
blur: string;
|
|
352
|
+
isPlaying: boolean;
|
|
353
|
+
}) {
|
|
354
|
+
return (
|
|
355
|
+
<>
|
|
356
|
+
{gradients.map((g, i) => {
|
|
357
|
+
const isCenter = 'isCenter' in g && g.isCenter;
|
|
358
|
+
const scale = 'scale' in g ? g.scale : 1;
|
|
359
|
+
const rotation = 'rotation' in g ? g.rotation : 0;
|
|
360
|
+
const itemBlur = 'blur' in g ? g.blur : 'blur-2xl';
|
|
361
|
+
|
|
362
|
+
return (
|
|
363
|
+
<div
|
|
364
|
+
key={i}
|
|
365
|
+
className={cn('absolute rounded-full -z-10', itemBlur)}
|
|
366
|
+
style={{
|
|
367
|
+
width: g.width,
|
|
368
|
+
height: g.height,
|
|
369
|
+
top: 'top' in g ? g.top : undefined,
|
|
370
|
+
bottom: 'bottom' in g ? g.bottom : undefined,
|
|
371
|
+
left: 'left' in g ? g.left : undefined,
|
|
372
|
+
right: 'right' in g ? g.right : undefined,
|
|
373
|
+
background: isCenter
|
|
374
|
+
? `radial-gradient(circle, hsl(${g.color} / 0.6) 0%, hsl(${g.color} / 0.3) 30%, transparent 60%)`
|
|
375
|
+
: `radial-gradient(circle, hsl(${g.color}) 0%, hsl(${g.color} / 0.5) 30%, transparent 65%)`,
|
|
376
|
+
opacity: isPlaying ? g.opacity : 0,
|
|
377
|
+
transform: isCenter
|
|
378
|
+
? `translate(-50%, -50%) scale(${scale})`
|
|
379
|
+
: `scale(${scale}) rotate(${rotation}deg)`,
|
|
380
|
+
transition: 'all 0.08s ease-out',
|
|
381
|
+
}}
|
|
382
|
+
/>
|
|
383
|
+
);
|
|
384
|
+
})}
|
|
385
|
+
</>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export default AudioReactiveCover;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AudioShortcutsPopover
|
|
5
|
+
*
|
|
6
|
+
* Displays keyboard shortcuts for audio player controls.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
Popover,
|
|
11
|
+
PopoverContent,
|
|
12
|
+
PopoverTrigger,
|
|
13
|
+
Button,
|
|
14
|
+
Kbd,
|
|
15
|
+
KbdGroup,
|
|
16
|
+
Separator,
|
|
17
|
+
Tooltip,
|
|
18
|
+
TooltipTrigger,
|
|
19
|
+
TooltipContent,
|
|
20
|
+
} from '@djangocfg/ui-nextjs';
|
|
21
|
+
import { Keyboard } from 'lucide-react';
|
|
22
|
+
import { AUDIO_SHORTCUTS } from './useAudioHotkeys';
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// COMPONENT
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
interface AudioShortcutsPopoverProps {
|
|
29
|
+
/** Compact mode for smaller displays */
|
|
30
|
+
compact?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function AudioShortcutsPopover({ compact = false }: AudioShortcutsPopoverProps) {
|
|
34
|
+
const trigger = (
|
|
35
|
+
<PopoverTrigger asChild>
|
|
36
|
+
<Button
|
|
37
|
+
variant="ghost"
|
|
38
|
+
size="icon"
|
|
39
|
+
className={compact ? 'h-6 w-6' : 'size-7 text-muted-foreground hover:text-foreground'}
|
|
40
|
+
title={compact ? undefined : 'Keyboard shortcuts'}
|
|
41
|
+
>
|
|
42
|
+
<Keyboard className={compact ? 'h-3.5 w-3.5' : 'size-4'} />
|
|
43
|
+
</Button>
|
|
44
|
+
</PopoverTrigger>
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Popover>
|
|
49
|
+
{compact ? (
|
|
50
|
+
<Tooltip>
|
|
51
|
+
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
|
52
|
+
<TooltipContent side="bottom">Shortcuts</TooltipContent>
|
|
53
|
+
</Tooltip>
|
|
54
|
+
) : (
|
|
55
|
+
trigger
|
|
56
|
+
)}
|
|
57
|
+
<PopoverContent className="w-56 p-0" align="end">
|
|
58
|
+
<div className="px-3 py-2 border-b">
|
|
59
|
+
<h4 className="font-medium text-sm">Player Shortcuts</h4>
|
|
60
|
+
</div>
|
|
61
|
+
<div className="p-2 space-y-3 max-h-72 overflow-y-auto">
|
|
62
|
+
{AUDIO_SHORTCUTS.map((group, groupIndex) => (
|
|
63
|
+
<div key={group.title}>
|
|
64
|
+
{groupIndex > 0 && <Separator className="my-2" />}
|
|
65
|
+
<div className="text-xs font-medium text-muted-foreground mb-1.5 px-1">
|
|
66
|
+
{group.title}
|
|
67
|
+
</div>
|
|
68
|
+
<div className="space-y-1">
|
|
69
|
+
{group.shortcuts.map((shortcut) => (
|
|
70
|
+
<div
|
|
71
|
+
key={shortcut.label}
|
|
72
|
+
className="flex items-center justify-between px-1 py-0.5"
|
|
73
|
+
>
|
|
74
|
+
<span className="text-sm text-foreground">
|
|
75
|
+
{shortcut.label}
|
|
76
|
+
</span>
|
|
77
|
+
<KbdGroup>
|
|
78
|
+
{shortcut.keys.map((key, i) => (
|
|
79
|
+
<Kbd key={i} size="sm">
|
|
80
|
+
{key}
|
|
81
|
+
</Kbd>
|
|
82
|
+
))}
|
|
83
|
+
</KbdGroup>
|
|
84
|
+
</div>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
))}
|
|
89
|
+
</div>
|
|
90
|
+
</PopoverContent>
|
|
91
|
+
</Popover>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export default AudioShortcutsPopover;
|