@djangocfg/ui-tools 2.1.201 → 2.1.203
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/README.md +22 -4
- package/dist/{chunk-7HP3GZFT.mjs → chunk-O55KZXKD.mjs} +123 -11
- package/dist/chunk-O55KZXKD.mjs.map +1 -0
- package/dist/{chunk-KHHTBDWW.cjs → chunk-XMZWMGKE.cjs} +122 -9
- package/dist/chunk-XMZWMGKE.cjs.map +1 -0
- package/dist/components-OZEGOPNP.cjs +46 -0
- package/dist/{components-7L3KMPQ5.cjs.map → components-OZEGOPNP.cjs.map} +1 -1
- package/dist/components-R4CC6JGG.mjs +5 -0
- package/dist/{components-IPSHDNXP.mjs.map → components-R4CC6JGG.mjs.map} +1 -1
- package/dist/index.cjs +48 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +49 -2
- package/dist/index.d.ts +49 -2
- package/dist/index.mjs +15 -4
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
- package/src/index.ts +2 -0
- package/src/tools/AudioPlayer/AudioPlayer.story.tsx +26 -5
- package/src/tools/AudioPlayer/README.md +38 -2
- package/src/tools/AudioPlayer/components/HybridCompactPlayer.tsx +153 -0
- package/src/tools/AudioPlayer/components/HybridSimplePlayer.tsx +9 -5
- package/src/tools/AudioPlayer/components/ReactiveCover/AudioReactiveCover.tsx +3 -1
- package/src/tools/AudioPlayer/components/index.ts +1 -0
- package/src/tools/AudioPlayer/hooks/index.ts +4 -0
- package/src/tools/AudioPlayer/hooks/useAudioBus.ts +76 -0
- package/src/tools/AudioPlayer/hooks/useHybridAudio.ts +18 -2
- package/src/tools/AudioPlayer/index.ts +6 -0
- package/src/tools/AudioPlayer/lazy.tsx +21 -1
- package/src/tools/Uploader/README.md +31 -6
- package/src/tools/Uploader/Uploader.story.tsx +44 -0
- package/src/tools/Uploader/components/UploadDropzone.tsx +13 -1
- package/src/tools/Uploader/components/UploadPreviewItem.tsx +10 -5
- package/src/tools/Uploader/hooks/useClipboardPaste.ts +15 -4
- package/src/tools/Uploader/index.ts +1 -0
- package/src/tools/Uploader/types/index.ts +7 -0
- package/src/tools/Uploader/utils/formatters.ts +28 -0
- package/src/tools/Uploader/utils/index.ts +1 -1
- package/dist/chunk-7HP3GZFT.mjs.map +0 -1
- package/dist/chunk-KHHTBDWW.cjs.map +0 -1
- package/dist/components-7L3KMPQ5.cjs +0 -42
- package/dist/components-IPSHDNXP.mjs +0 -5
|
@@ -13,11 +13,14 @@ Audio player with native HTML5 streaming and audio-reactive visualizations.
|
|
|
13
13
|
## Quick Start
|
|
14
14
|
|
|
15
15
|
```tsx
|
|
16
|
-
import { HybridSimplePlayer } from '@djangocfg/ui-nextjs';
|
|
16
|
+
import { HybridSimplePlayer, HybridCompactPlayer } from '@djangocfg/ui-nextjs';
|
|
17
17
|
|
|
18
18
|
// Simple usage
|
|
19
19
|
<HybridSimplePlayer src="https://example.com/audio.mp3" />
|
|
20
20
|
|
|
21
|
+
// Compact single-row player (for lists, sidebars)
|
|
22
|
+
<HybridCompactPlayer src="https://example.com/audio.mp3" title="Rain & Thunder" />
|
|
23
|
+
|
|
21
24
|
// With metadata and reactive cover
|
|
22
25
|
<HybridSimplePlayer
|
|
23
26
|
src={audioUrl}
|
|
@@ -156,6 +159,38 @@ Album art wrapper with audio-reactive effects:
|
|
|
156
159
|
</AudioReactiveCover>
|
|
157
160
|
```
|
|
158
161
|
|
|
162
|
+
### HybridCompactPlayer
|
|
163
|
+
|
|
164
|
+
Single-row player — play/pause + waveform + timer. No cover art, no volume slider.
|
|
165
|
+
|
|
166
|
+
```tsx
|
|
167
|
+
<HybridCompactPlayer
|
|
168
|
+
src={audioUrl}
|
|
169
|
+
title="Track name" // used as tooltip
|
|
170
|
+
waveformMode="frequency" // 'frequency' | 'static'
|
|
171
|
+
showTimer={true}
|
|
172
|
+
autoPlay={false}
|
|
173
|
+
/>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
| Prop | Type | Default | Description |
|
|
177
|
+
|------|------|---------|-------------|
|
|
178
|
+
| `src` | `string` | required | Audio URL |
|
|
179
|
+
| `title` | `string` | - | Tooltip / aria-label |
|
|
180
|
+
| `waveformMode` | `'frequency' \| 'static'` | `'frequency'` | Visualization mode |
|
|
181
|
+
| `showTimer` | `boolean` | `true` | Show current/total time |
|
|
182
|
+
| `autoPlay` | `boolean` | `false` | Auto-play on load |
|
|
183
|
+
| `loop` | `boolean` | `false` | Loop playback |
|
|
184
|
+
| `initialVolume` | `number` | `1` | Initial volume 0-1 |
|
|
185
|
+
|
|
186
|
+
For lazy-loading (preferred in Next.js apps):
|
|
187
|
+
|
|
188
|
+
```tsx
|
|
189
|
+
import { LazyHybridCompactPlayer } from '@djangocfg/ui-tools';
|
|
190
|
+
|
|
191
|
+
<LazyHybridCompactPlayer src={url} title="Track" autoPlay />
|
|
192
|
+
```
|
|
193
|
+
|
|
159
194
|
### HybridWaveform
|
|
160
195
|
|
|
161
196
|
Real-time frequency visualization:
|
|
@@ -193,7 +228,8 @@ AudioPlayer/
|
|
|
193
228
|
│ └── HybridAudioProvider.tsx # Audio context provider
|
|
194
229
|
├── components/
|
|
195
230
|
│ ├── HybridAudioPlayer.tsx # Main player component
|
|
196
|
-
│ ├── HybridSimplePlayer.tsx # All-in-one wrapper
|
|
231
|
+
│ ├── HybridSimplePlayer.tsx # All-in-one wrapper (with cover, volume, effects)
|
|
232
|
+
│ ├── HybridCompactPlayer.tsx # Compact single-row player
|
|
197
233
|
│ ├── HybridWaveform.tsx # Frequency visualization
|
|
198
234
|
│ └── ReactiveCover/ # Reactive effects
|
|
199
235
|
├── effects/ # Effect calculations
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HybridCompactPlayer - Single-row audio player
|
|
5
|
+
*
|
|
6
|
+
* Designed for tight spaces: play/pause + waveform + timer.
|
|
7
|
+
* No cover art, no volume slider, no skip buttons.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* <HybridCompactPlayer src="https://example.com/audio.mp3" title="Rain & Thunder" />
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* // Lazy-loaded (preferred in app)
|
|
14
|
+
* <LazyHybridCompactPlayer src={url} autoPlay />
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { type ReactNode } from 'react';
|
|
18
|
+
import { Play, Pause, Loader2 } from 'lucide-react';
|
|
19
|
+
import { cn } from '@djangocfg/ui-core';
|
|
20
|
+
import { Button } from '../../_shared';
|
|
21
|
+
|
|
22
|
+
import { HybridAudioProvider } from '../context/HybridAudioProvider';
|
|
23
|
+
import { HybridWaveform } from './HybridWaveform';
|
|
24
|
+
import { useHybridAudioContext } from '../context/HybridAudioProvider';
|
|
25
|
+
import { formatTime } from '../utils';
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// TYPES
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
export interface HybridCompactPlayerProps {
|
|
32
|
+
/** Audio source URL */
|
|
33
|
+
src: string;
|
|
34
|
+
/** Track title (shown as tooltip / aria-label) */
|
|
35
|
+
title?: string;
|
|
36
|
+
/** Auto-play on load */
|
|
37
|
+
autoPlay?: boolean;
|
|
38
|
+
/** Loop playback */
|
|
39
|
+
loop?: boolean;
|
|
40
|
+
/** Initial volume (0-1) */
|
|
41
|
+
initialVolume?: number;
|
|
42
|
+
/** Waveform visualization mode */
|
|
43
|
+
waveformMode?: 'frequency' | 'static';
|
|
44
|
+
/** Show timer */
|
|
45
|
+
showTimer?: boolean;
|
|
46
|
+
/** Additional class name */
|
|
47
|
+
className?: string;
|
|
48
|
+
/** Callbacks */
|
|
49
|
+
onPlay?: () => void;
|
|
50
|
+
onPause?: () => void;
|
|
51
|
+
onEnded?: () => void;
|
|
52
|
+
onError?: (error: Error) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// =============================================================================
|
|
56
|
+
// COMPONENT
|
|
57
|
+
// =============================================================================
|
|
58
|
+
|
|
59
|
+
export function HybridCompactPlayer({
|
|
60
|
+
src,
|
|
61
|
+
title,
|
|
62
|
+
autoPlay = false,
|
|
63
|
+
loop = false,
|
|
64
|
+
initialVolume = 1,
|
|
65
|
+
waveformMode = 'frequency',
|
|
66
|
+
showTimer = true,
|
|
67
|
+
className,
|
|
68
|
+
onPlay,
|
|
69
|
+
onPause,
|
|
70
|
+
onEnded,
|
|
71
|
+
onError,
|
|
72
|
+
}: HybridCompactPlayerProps): ReactNode {
|
|
73
|
+
return (
|
|
74
|
+
<HybridAudioProvider
|
|
75
|
+
src={src}
|
|
76
|
+
autoPlay={autoPlay}
|
|
77
|
+
loop={loop}
|
|
78
|
+
initialVolume={initialVolume}
|
|
79
|
+
onPlay={onPlay}
|
|
80
|
+
onPause={onPause}
|
|
81
|
+
onEnded={onEnded}
|
|
82
|
+
onError={onError}
|
|
83
|
+
>
|
|
84
|
+
<HybridCompactPlayerInner
|
|
85
|
+
title={title}
|
|
86
|
+
waveformMode={waveformMode}
|
|
87
|
+
showTimer={showTimer}
|
|
88
|
+
className={className}
|
|
89
|
+
/>
|
|
90
|
+
</HybridAudioProvider>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// =============================================================================
|
|
95
|
+
// INNER (needs context)
|
|
96
|
+
// =============================================================================
|
|
97
|
+
|
|
98
|
+
interface InnerProps {
|
|
99
|
+
title?: string;
|
|
100
|
+
waveformMode: 'frequency' | 'static';
|
|
101
|
+
showTimer: boolean;
|
|
102
|
+
className?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function HybridCompactPlayerInner({ title, waveformMode, showTimer, className }: InnerProps) {
|
|
106
|
+
const { state, controls } = useHybridAudioContext();
|
|
107
|
+
const isLoading = !state.isReady;
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div className={cn('flex items-center gap-2 w-full', className)}>
|
|
111
|
+
{/* Play / Pause */}
|
|
112
|
+
<Button
|
|
113
|
+
variant="outline"
|
|
114
|
+
size="icon"
|
|
115
|
+
className="h-8 w-8 flex-shrink-0"
|
|
116
|
+
onClick={controls.togglePlay}
|
|
117
|
+
disabled={!state.isReady && !isLoading}
|
|
118
|
+
title={title ?? (state.isPlaying ? 'Pause' : 'Play')}
|
|
119
|
+
aria-label={state.isPlaying ? 'Pause' : 'Play'}
|
|
120
|
+
>
|
|
121
|
+
{isLoading ? (
|
|
122
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
123
|
+
) : state.isPlaying ? (
|
|
124
|
+
<Pause className="h-3.5 w-3.5" />
|
|
125
|
+
) : (
|
|
126
|
+
<Play className="h-3.5 w-3.5 ml-0.5" />
|
|
127
|
+
)}
|
|
128
|
+
</Button>
|
|
129
|
+
|
|
130
|
+
{/* Waveform */}
|
|
131
|
+
<div className="flex-1 min-w-0">
|
|
132
|
+
<HybridWaveform
|
|
133
|
+
mode={waveformMode}
|
|
134
|
+
height={32}
|
|
135
|
+
barWidth={2}
|
|
136
|
+
barGap={1}
|
|
137
|
+
className={cn(isLoading && 'opacity-40')}
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{/* Timer */}
|
|
142
|
+
{showTimer && (
|
|
143
|
+
<span className="text-[11px] text-muted-foreground tabular-nums flex-shrink-0">
|
|
144
|
+
{formatTime(state.currentTime)}
|
|
145
|
+
<span className="text-muted-foreground/50"> / </span>
|
|
146
|
+
{formatTime(state.duration)}
|
|
147
|
+
</span>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export default HybridCompactPlayer;
|
|
@@ -118,6 +118,10 @@ const COVER_SIZES = {
|
|
|
118
118
|
lg: 'w-48 h-48',
|
|
119
119
|
};
|
|
120
120
|
|
|
121
|
+
// In horizontal layout cover stays fixed-size; in vertical it fills the full width
|
|
122
|
+
const COVER_SIZE_FOR_LAYOUT = (coverSize: 'sm' | 'md' | 'lg', isHorizontal: boolean) =>
|
|
123
|
+
isHorizontal ? coverSize : 'full';
|
|
124
|
+
|
|
121
125
|
// =============================================================================
|
|
122
126
|
// COMPONENT
|
|
123
127
|
// =============================================================================
|
|
@@ -209,16 +213,16 @@ function HybridSimplePlayerContent({
|
|
|
209
213
|
>
|
|
210
214
|
{/* Cover Art */}
|
|
211
215
|
{(coverArt || reactiveCover) && (
|
|
212
|
-
<div className=
|
|
216
|
+
<div className={cn('flex flex-col items-center gap-2', isHorizontal ? 'shrink-0' : 'w-full')}>
|
|
213
217
|
{showReactiveCover ? (
|
|
214
218
|
<AudioReactiveCover
|
|
215
|
-
size={coverSize}
|
|
219
|
+
size={COVER_SIZE_FOR_LAYOUT(coverSize, isHorizontal)}
|
|
216
220
|
variant={effectiveVariant as 'glow' | 'orbs' | 'spotlight' | 'mesh'}
|
|
217
221
|
intensity={effectiveIntensity}
|
|
218
222
|
colorScheme={effectiveColorScheme}
|
|
219
223
|
onClick={nextVariant}
|
|
220
224
|
>
|
|
221
|
-
<div className={cn('rounded-lg overflow-hidden', COVER_SIZES[coverSize])}>
|
|
225
|
+
<div className={cn('rounded-lg overflow-hidden', isHorizontal ? COVER_SIZES[coverSize] : 'w-full h-full')}>
|
|
222
226
|
{renderCoverContent()}
|
|
223
227
|
</div>
|
|
224
228
|
</AudioReactiveCover>
|
|
@@ -226,7 +230,7 @@ function HybridSimplePlayerContent({
|
|
|
226
230
|
<div
|
|
227
231
|
className={cn(
|
|
228
232
|
'rounded-lg overflow-hidden shadow-lg cursor-pointer',
|
|
229
|
-
COVER_SIZES[coverSize]
|
|
233
|
+
isHorizontal ? COVER_SIZES[coverSize] : 'w-full sm:max-w-xs aspect-square mx-auto'
|
|
230
234
|
)}
|
|
231
235
|
onClick={nextVariant}
|
|
232
236
|
role="button"
|
|
@@ -248,7 +252,7 @@ function HybridSimplePlayerContent({
|
|
|
248
252
|
|
|
249
253
|
{/* Track Info + Player */}
|
|
250
254
|
<div
|
|
251
|
-
className={cn('flex flex-col gap-3', isHorizontal ? 'flex-1 min-w-0' : 'w-full
|
|
255
|
+
className={cn('flex flex-col gap-3', isHorizontal ? 'flex-1 min-w-0' : 'w-full')}
|
|
252
256
|
>
|
|
253
257
|
{/* Track Info */}
|
|
254
258
|
{(title || artist) && (
|
|
@@ -32,7 +32,8 @@ import { GlowEffect, OrbsEffect, SpotlightEffect, MeshEffect, type GlowEffectDat
|
|
|
32
32
|
|
|
33
33
|
export interface AudioReactiveCoverProps {
|
|
34
34
|
children: ReactNode;
|
|
35
|
-
|
|
35
|
+
/** 'sm' | 'md' | 'lg' = fixed sizes; 'full' = stretch to container width (aspect-square) */
|
|
36
|
+
size?: 'sm' | 'md' | 'lg' | 'full';
|
|
36
37
|
variant?: EffectVariant;
|
|
37
38
|
intensity?: EffectIntensity;
|
|
38
39
|
colorScheme?: EffectColorScheme;
|
|
@@ -48,6 +49,7 @@ const SIZES = {
|
|
|
48
49
|
sm: { container: 'w-32 h-32', orbBase: 40 },
|
|
49
50
|
md: { container: 'w-40 h-40', orbBase: 50 },
|
|
50
51
|
lg: { container: 'w-48 h-48', orbBase: 60 },
|
|
52
|
+
full: { container: 'w-full sm:max-w-xs aspect-square mx-auto', orbBase: 60 },
|
|
51
53
|
};
|
|
52
54
|
|
|
53
55
|
// =============================================================================
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// Player components
|
|
9
9
|
export { HybridAudioPlayer, type HybridAudioPlayerProps } from './HybridAudioPlayer';
|
|
10
10
|
export { HybridSimplePlayer, type HybridSimplePlayerProps } from './HybridSimplePlayer';
|
|
11
|
+
export { HybridCompactPlayer, type HybridCompactPlayerProps } from './HybridCompactPlayer';
|
|
11
12
|
export { HybridWaveform, type HybridWaveformProps } from './HybridWaveform';
|
|
12
13
|
|
|
13
14
|
// ReactiveCover
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
* AudioPlayer hooks - Public API
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
// Audio bus — global exclusivity (one player at a time)
|
|
6
|
+
export { useAudioBus, useAudioBusStore } from './useAudioBus';
|
|
7
|
+
export type { UseAudioBusReturn } from './useAudioBus';
|
|
8
|
+
|
|
5
9
|
// Core hybrid audio hook
|
|
6
10
|
export { useHybridAudio } from './useHybridAudio';
|
|
7
11
|
export type {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useAudioBus — Global audio exclusivity via Zustand store.
|
|
5
|
+
*
|
|
6
|
+
* Ensures only one audio player plays at a time across the entire page.
|
|
7
|
+
* No provider needed — singleton store, works anywhere in the tree.
|
|
8
|
+
*
|
|
9
|
+
* Also integrates with @djangocfg/ui-tools AudioPlayer automatically
|
|
10
|
+
* (wired into useHybridAudio).
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const { announce } = useAudioBus('my-player-id', () => pause());
|
|
14
|
+
*
|
|
15
|
+
* // When playback starts:
|
|
16
|
+
* announce(); // all other players will pause
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useEffect, useRef, useCallback } from 'react';
|
|
20
|
+
import { create } from 'zustand';
|
|
21
|
+
|
|
22
|
+
// =============================================================================
|
|
23
|
+
// Store
|
|
24
|
+
// =============================================================================
|
|
25
|
+
|
|
26
|
+
interface AudioBusStore {
|
|
27
|
+
/** ID of the currently active (playing) player, null if none */
|
|
28
|
+
activeId: string | null;
|
|
29
|
+
setActiveId: (id: string | null) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const useAudioBusStore = create<AudioBusStore>((set) => ({
|
|
33
|
+
activeId: null,
|
|
34
|
+
setActiveId: (id) => set({ activeId: id }),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
// =============================================================================
|
|
38
|
+
// Hook
|
|
39
|
+
// =============================================================================
|
|
40
|
+
|
|
41
|
+
export interface UseAudioBusReturn {
|
|
42
|
+
/** Broadcast: this player is now playing — all others should stop */
|
|
43
|
+
announce: () => void;
|
|
44
|
+
/** Release: this player stopped (clears activeId if it was ours) */
|
|
45
|
+
release: () => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param playerId Stable unique ID for this player instance
|
|
50
|
+
* @param onStop Called when another player announces — should pause this player
|
|
51
|
+
*/
|
|
52
|
+
export function useAudioBus(playerId: string, onStop: () => void): UseAudioBusReturn {
|
|
53
|
+
const onStopRef = useRef(onStop);
|
|
54
|
+
onStopRef.current = onStop;
|
|
55
|
+
|
|
56
|
+
const setActiveId = useAudioBusStore((s) => s.setActiveId);
|
|
57
|
+
|
|
58
|
+
// Subscribe to store changes — if another player becomes active, stop this one
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
return useAudioBusStore.subscribe((state) => {
|
|
61
|
+
if (state.activeId !== null && state.activeId !== playerId) {
|
|
62
|
+
onStopRef.current();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}, [playerId]);
|
|
66
|
+
|
|
67
|
+
const announce = useCallback(() => {
|
|
68
|
+
setActiveId(playerId);
|
|
69
|
+
}, [playerId, setActiveId]);
|
|
70
|
+
|
|
71
|
+
const release = useCallback(() => {
|
|
72
|
+
useAudioBusStore.setState((s) => s.activeId === playerId ? { activeId: null } : s);
|
|
73
|
+
}, [playerId]);
|
|
74
|
+
|
|
75
|
+
return { announce, release };
|
|
76
|
+
}
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* source -> analyser (parallel path for visualization only, no output)
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
14
|
+
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
15
|
+
import { useAudioBus } from './useAudioBus';
|
|
15
16
|
|
|
16
17
|
// =============================================================================
|
|
17
18
|
// TYPES
|
|
@@ -23,6 +24,8 @@ export interface UseHybridAudioOptions {
|
|
|
23
24
|
initialVolume?: number;
|
|
24
25
|
loop?: boolean;
|
|
25
26
|
crossOrigin?: 'anonymous' | 'use-credentials';
|
|
27
|
+
/** Set to true to opt out of global audio bus (player won't stop others and won't be stopped) */
|
|
28
|
+
excludeFromBus?: boolean;
|
|
26
29
|
onPlay?: () => void;
|
|
27
30
|
onPause?: () => void;
|
|
28
31
|
onEnded?: () => void;
|
|
@@ -81,6 +84,7 @@ export function useHybridAudio(options: UseHybridAudioOptions): UseHybridAudioRe
|
|
|
81
84
|
initialVolume = 1,
|
|
82
85
|
loop = false,
|
|
83
86
|
crossOrigin = 'anonymous',
|
|
87
|
+
excludeFromBus = false,
|
|
84
88
|
onPlay,
|
|
85
89
|
onPause,
|
|
86
90
|
onEnded,
|
|
@@ -89,6 +93,8 @@ export function useHybridAudio(options: UseHybridAudioOptions): UseHybridAudioRe
|
|
|
89
93
|
onReady,
|
|
90
94
|
} = options;
|
|
91
95
|
|
|
96
|
+
const playerId = useId();
|
|
97
|
+
|
|
92
98
|
// Refs
|
|
93
99
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
94
100
|
const audioContextRef = useRef<AudioContext | null>(null);
|
|
@@ -162,6 +168,14 @@ export function useHybridAudio(options: UseHybridAudioOptions): UseHybridAudioRe
|
|
|
162
168
|
}
|
|
163
169
|
}, []);
|
|
164
170
|
|
|
171
|
+
// Audio bus — stop other players when this one plays
|
|
172
|
+
const { announce: busAnnounce, release: busRelease } = useAudioBus(
|
|
173
|
+
playerId,
|
|
174
|
+
useCallback(() => {
|
|
175
|
+
if (!excludeFromBus) audioRef.current?.pause();
|
|
176
|
+
}, [excludeFromBus])
|
|
177
|
+
);
|
|
178
|
+
|
|
165
179
|
// Resume AudioContext on user interaction
|
|
166
180
|
const resumeAudioContext = useCallback(async () => {
|
|
167
181
|
const ctx = audioContextRef.current;
|
|
@@ -176,6 +190,7 @@ export function useHybridAudio(options: UseHybridAudioOptions): UseHybridAudioRe
|
|
|
176
190
|
if (!audio) return;
|
|
177
191
|
|
|
178
192
|
try {
|
|
193
|
+
if (!excludeFromBus) busAnnounce();
|
|
179
194
|
initWebAudio();
|
|
180
195
|
await resumeAudioContext();
|
|
181
196
|
await audio.play();
|
|
@@ -183,7 +198,7 @@ export function useHybridAudio(options: UseHybridAudioOptions): UseHybridAudioRe
|
|
|
183
198
|
console.error('[useHybridAudio] Play failed:', error);
|
|
184
199
|
onError?.(error as Error);
|
|
185
200
|
}
|
|
186
|
-
}, [initWebAudio, resumeAudioContext, onError]);
|
|
201
|
+
}, [excludeFromBus, busAnnounce, initWebAudio, resumeAudioContext, onError]);
|
|
187
202
|
|
|
188
203
|
const pause = useCallback(() => {
|
|
189
204
|
audioRef.current?.pause();
|
|
@@ -286,6 +301,7 @@ export function useHybridAudio(options: UseHybridAudioOptions): UseHybridAudioRe
|
|
|
286
301
|
return () => {
|
|
287
302
|
audio.pause();
|
|
288
303
|
audio.src = '';
|
|
304
|
+
busRelease();
|
|
289
305
|
if (audioContextRef.current) {
|
|
290
306
|
audioContextRef.current.close().catch(() => {});
|
|
291
307
|
}
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
export {
|
|
25
25
|
HybridAudioPlayer,
|
|
26
26
|
HybridSimplePlayer,
|
|
27
|
+
HybridCompactPlayer,
|
|
27
28
|
HybridWaveform,
|
|
28
29
|
AudioReactiveCover,
|
|
29
30
|
// Effect components (for custom implementations)
|
|
@@ -36,6 +37,7 @@ export {
|
|
|
36
37
|
export type {
|
|
37
38
|
HybridAudioPlayerProps,
|
|
38
39
|
HybridSimplePlayerProps,
|
|
40
|
+
HybridCompactPlayerProps,
|
|
39
41
|
HybridWaveformProps,
|
|
40
42
|
AudioReactiveCoverProps,
|
|
41
43
|
GlowEffectData,
|
|
@@ -64,6 +66,9 @@ export type {
|
|
|
64
66
|
// =============================================================================
|
|
65
67
|
|
|
66
68
|
export {
|
|
69
|
+
// Audio bus
|
|
70
|
+
useAudioBus,
|
|
71
|
+
useAudioBusStore,
|
|
67
72
|
// Core hooks
|
|
68
73
|
useHybridAudio,
|
|
69
74
|
useHybridAudioAnalysis,
|
|
@@ -77,6 +82,7 @@ export {
|
|
|
77
82
|
} from './hooks';
|
|
78
83
|
|
|
79
84
|
export type {
|
|
85
|
+
UseAudioBusReturn,
|
|
80
86
|
UseHybridAudioOptions,
|
|
81
87
|
HybridAudioState,
|
|
82
88
|
HybridAudioControls,
|
|
@@ -14,13 +14,14 @@ import { createLazyComponent, LoadingFallback } from '../../components';
|
|
|
14
14
|
import type {
|
|
15
15
|
HybridAudioPlayerProps,
|
|
16
16
|
HybridSimplePlayerProps,
|
|
17
|
+
HybridCompactPlayerProps,
|
|
17
18
|
} from './components';
|
|
18
19
|
|
|
19
20
|
// ============================================================================
|
|
20
21
|
// Re-export types
|
|
21
22
|
// ============================================================================
|
|
22
23
|
|
|
23
|
-
export type { HybridAudioPlayerProps, HybridSimplePlayerProps };
|
|
24
|
+
export type { HybridAudioPlayerProps, HybridSimplePlayerProps, HybridCompactPlayerProps };
|
|
24
25
|
|
|
25
26
|
// ============================================================================
|
|
26
27
|
// Audio Loading Fallback
|
|
@@ -83,3 +84,22 @@ export const LazyHybridSimplePlayer = createLazyComponent<HybridSimplePlayerProp
|
|
|
83
84
|
fallback: <AudioLoadingFallback />,
|
|
84
85
|
}
|
|
85
86
|
);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* LazyHybridCompactPlayer - Lazy-loaded compact single-row audio player
|
|
90
|
+
*
|
|
91
|
+
* Use in tight spaces: play/pause + waveform + timer in one line.
|
|
92
|
+
*/
|
|
93
|
+
export const LazyHybridCompactPlayer = createLazyComponent<HybridCompactPlayerProps>(
|
|
94
|
+
() => import('./components').then((mod) => ({ default: mod.HybridCompactPlayer })),
|
|
95
|
+
{
|
|
96
|
+
displayName: 'LazyHybridCompactPlayer',
|
|
97
|
+
fallback: (
|
|
98
|
+
<div className="flex items-center gap-2 h-8 px-1 animate-pulse">
|
|
99
|
+
<div className="h-8 w-8 rounded-md bg-muted flex-shrink-0" />
|
|
100
|
+
<div className="flex-1 h-4 rounded bg-muted" />
|
|
101
|
+
<div className="h-3 w-12 rounded bg-muted flex-shrink-0" />
|
|
102
|
+
</div>
|
|
103
|
+
),
|
|
104
|
+
}
|
|
105
|
+
);
|
|
@@ -49,7 +49,32 @@ All-in-one component with dropzone and preview list.
|
|
|
49
49
|
/>
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
###
|
|
52
|
+
### Standalone — custom upload handler (no UploadProvider)
|
|
53
|
+
|
|
54
|
+
If you handle uploads yourself (custom API hooks, multipart POST, etc.), pass `uploadFn` instead of wrapping with `UploadProvider`. Supports drag/drop, click, and Ctrl+V paste.
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
import { UploadDropzone } from '@djangocfg/ui-tools/upload';
|
|
58
|
+
|
|
59
|
+
function MyUploader() {
|
|
60
|
+
const { uploadAsset } = useAssets(); // your own API hook
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<UploadDropzone
|
|
64
|
+
accept={['image', 'video']}
|
|
65
|
+
maxSizeMB={50}
|
|
66
|
+
pasteEnabled
|
|
67
|
+
uploadFn={async (files) => {
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
await uploadAsset({ file, name: file.name });
|
|
70
|
+
}
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Custom Composition (with rpldy)
|
|
53
78
|
|
|
54
79
|
```tsx
|
|
55
80
|
import {
|
|
@@ -116,11 +141,11 @@ Custom overlay:
|
|
|
116
141
|
|
|
117
142
|
### Components
|
|
118
143
|
|
|
119
|
-
| Component | Description |
|
|
120
|
-
|
|
121
|
-
| `Uploader` | All-in-one (Provider + Dropzone + Preview) |
|
|
122
|
-
| `
|
|
123
|
-
| `
|
|
144
|
+
| Component | Needs UploadProvider | Description |
|
|
145
|
+
|-----------|---------------------|-------------|
|
|
146
|
+
| `Uploader` | Yes | All-in-one (Provider + Dropzone + Preview) |
|
|
147
|
+
| `UploadDropzone` | **Optional** | Drag-drop zone — use `uploadFn` for standalone mode |
|
|
148
|
+
| `UploadProvider` | — | Context provider wrapping @rpldy/uploady |
|
|
124
149
|
| `UploadPreviewList` | List of upload items with progress |
|
|
125
150
|
| `UploadPreviewItem` | Single item (thumbnail, status, actions) |
|
|
126
151
|
| `UploadAddButton` | Button to add files |
|
|
@@ -369,3 +369,47 @@ export const PageDropCustomOverlay = () => (
|
|
|
369
369
|
</UploadProvider>
|
|
370
370
|
</div>
|
|
371
371
|
);
|
|
372
|
+
|
|
373
|
+
// Standalone — uploadFn instead of UploadProvider (custom API hooks, no rpldy)
|
|
374
|
+
export const StandaloneWithUploadFn = () => {
|
|
375
|
+
const [files, setFiles] = useState<string[]>([]);
|
|
376
|
+
|
|
377
|
+
return (
|
|
378
|
+
<div className="max-w-2xl space-y-4">
|
|
379
|
+
<Card>
|
|
380
|
+
<CardContent className="pt-4">
|
|
381
|
+
<p className="text-sm text-muted-foreground flex items-center gap-2">
|
|
382
|
+
<ClipboardPaste className="h-4 w-4" />
|
|
383
|
+
No <code className="text-xs bg-muted px-1 rounded">UploadProvider</code> needed.
|
|
384
|
+
Pass <code className="text-xs bg-muted px-1 rounded">uploadFn</code> to handle files yourself.
|
|
385
|
+
Drag, click, or paste (Ctrl+V).
|
|
386
|
+
</p>
|
|
387
|
+
</CardContent>
|
|
388
|
+
</Card>
|
|
389
|
+
<UploadDropzone
|
|
390
|
+
accept={['image', 'document']}
|
|
391
|
+
maxSizeMB={10}
|
|
392
|
+
pasteEnabled
|
|
393
|
+
uploadFn={(selected) => {
|
|
394
|
+
setFiles((prev) => [...prev, ...selected.map((f) => f.name)]);
|
|
395
|
+
logger.info('Custom uploadFn received: ' + selected.map((f) => f.name).join(', '));
|
|
396
|
+
}}
|
|
397
|
+
onPasteNoMatch={() => logger.info('Paste: no uploadable content found')}
|
|
398
|
+
/>
|
|
399
|
+
{files.length > 0 && (
|
|
400
|
+
<Card>
|
|
401
|
+
<CardHeader>
|
|
402
|
+
<CardTitle className="text-sm">Received by uploadFn</CardTitle>
|
|
403
|
+
</CardHeader>
|
|
404
|
+
<CardContent>
|
|
405
|
+
<ul className="text-sm space-y-1">
|
|
406
|
+
{files.map((name, i) => (
|
|
407
|
+
<li key={i} className="text-muted-foreground">{name}</li>
|
|
408
|
+
))}
|
|
409
|
+
</ul>
|
|
410
|
+
</CardContent>
|
|
411
|
+
</Card>
|
|
412
|
+
)}
|
|
413
|
+
</div>
|
|
414
|
+
);
|
|
415
|
+
};
|
|
@@ -9,6 +9,17 @@ import { buildAcceptString, logger } from '../utils';
|
|
|
9
9
|
import { useClipboardPaste } from '../hooks/useClipboardPaste';
|
|
10
10
|
import type { UploadDropzoneProps } from '../types';
|
|
11
11
|
|
|
12
|
+
function useOptionalUploady(uploadFn?: (files: File[]) => void) {
|
|
13
|
+
try {
|
|
14
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
15
|
+
const { upload } = useUploady();
|
|
16
|
+
return uploadFn ?? upload;
|
|
17
|
+
} catch {
|
|
18
|
+
// Not inside UploadProvider — use uploadFn if provided, otherwise noop
|
|
19
|
+
return uploadFn ?? (() => {});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
12
23
|
export function UploadDropzone({
|
|
13
24
|
accept = ['image', 'audio', 'video', 'document'],
|
|
14
25
|
multiple = true,
|
|
@@ -18,11 +29,12 @@ export function UploadDropzone({
|
|
|
18
29
|
className,
|
|
19
30
|
children,
|
|
20
31
|
onFilesSelected,
|
|
32
|
+
uploadFn,
|
|
21
33
|
pasteEnabled = true,
|
|
22
34
|
onPasteNoMatch,
|
|
23
35
|
}: UploadDropzoneProps) {
|
|
24
36
|
const t = useT();
|
|
25
|
-
const
|
|
37
|
+
const upload = useOptionalUploady(uploadFn);
|
|
26
38
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
27
39
|
const [isDragging, setIsDragging] = useState(false);
|
|
28
40
|
const dragCounter = useRef(0);
|