uifolio 0.3.0 → 0.5.1
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 +15 -8
- package/cli/uifolio.mjs +538 -147
- package/package.json +25 -8
- package/src/components/ui/audio-player.tsx +1 -1
- package/src/components/ui/calendar.tsx +1 -1
- package/src/components/ui/choice.tsx +1 -1
- package/src/components/ui/color-picker.tsx +3 -3
- package/src/components/ui/command-menu.tsx +1 -1
- package/src/components/ui/file-upload.tsx +1 -1
- package/src/components/ui/icon-picker.tsx +28 -20
- package/src/components/ui/motion.tsx +10 -8
- package/src/components/ui/otp-input.tsx +7 -31
- package/src/components/ui/resizable.tsx +5 -5
- package/src/components/ui/rich-text-editor.tsx +3 -3
- package/src/components/ui/stories.tsx +13 -10
- package/src/components/ui/time-picker.tsx +1 -1
- package/src/components/ui/video-player.tsx +157 -117
- package/src/lib/use-controllable.ts +54 -0
- package/src/lib/video.ts +6 -5
|
@@ -27,12 +27,14 @@ import {
|
|
|
27
27
|
} from 'lucide-react'
|
|
28
28
|
import type Hls from 'hls.js'
|
|
29
29
|
import { cn } from '@/lib/utils'
|
|
30
|
+
import type {
|
|
31
|
+
ResolvedSubtitleTrack,
|
|
32
|
+
SubtitleTrack,
|
|
33
|
+
} from '@/lib/vtt'
|
|
30
34
|
import {
|
|
31
35
|
parseVtt,
|
|
32
36
|
subtitleSignature,
|
|
33
37
|
toSubtitleList,
|
|
34
|
-
type ResolvedSubtitleTrack,
|
|
35
|
-
type SubtitleTrack,
|
|
36
38
|
} from '@/lib/vtt'
|
|
37
39
|
import {
|
|
38
40
|
buildEmbedUrl,
|
|
@@ -55,6 +57,9 @@ import {
|
|
|
55
57
|
type ResolvedSource,
|
|
56
58
|
type SourceKind,
|
|
57
59
|
} from '@/lib/video'
|
|
60
|
+
import {
|
|
61
|
+
useControllableValue,
|
|
62
|
+
} from '@/lib/use-controllable'
|
|
58
63
|
|
|
59
64
|
export type VideoControlsMode = 'none' | 'minimal' | 'full'
|
|
60
65
|
export type VideoRounded = 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full'
|
|
@@ -163,44 +168,82 @@ const rangeThumb =
|
|
|
163
168
|
'[&::-webkit-slider-thumb]:size-2.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-sm [&::-webkit-slider-thumb]:border-0 [&::-webkit-slider-thumb]:bg-[var(--vp-accent)] [&::-moz-range-thumb]:size-2.5 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-sm [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-[var(--vp-accent)]'
|
|
164
169
|
|
|
165
170
|
/** Transforme les sources (URL, File, Blob, qualités) en sources lisibles et gère les object URLs. */
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
171
|
+
interface SourceResolver {
|
|
172
|
+
sources: ResolvedSource[]
|
|
173
|
+
signature: string
|
|
174
|
+
/** Révoque les object URLs créées pour cette résolution. */
|
|
175
|
+
dispose: () => void
|
|
176
|
+
}
|
|
170
177
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return {
|
|
182
|
-
src: url,
|
|
183
|
-
kind: 'native',
|
|
184
|
-
objectUrl: true,
|
|
185
|
-
type: source.type || undefined,
|
|
186
|
-
}
|
|
187
|
-
}
|
|
178
|
+
function resolveSources(input: PlayerSource | PlayerSource[]): SourceResolver {
|
|
179
|
+
const signature = sourceSignature(input)
|
|
180
|
+
const created: string[] = []
|
|
181
|
+
const sources: ResolvedSource[] = toSourceList(input).map<ResolvedSource>((source) => {
|
|
182
|
+
if (typeof source === 'string') {
|
|
183
|
+
return { src: source, kind: detectKind(source) }
|
|
184
|
+
}
|
|
185
|
+
if (source instanceof Blob) {
|
|
186
|
+
const url = URL.createObjectURL(source)
|
|
187
|
+
created.push(url)
|
|
188
188
|
return {
|
|
189
|
-
src:
|
|
190
|
-
kind:
|
|
191
|
-
|
|
192
|
-
|
|
189
|
+
src: url,
|
|
190
|
+
kind: 'native',
|
|
191
|
+
objectUrl: true,
|
|
192
|
+
type: source.type || undefined,
|
|
193
193
|
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
src: source.src,
|
|
197
|
+
kind: detectKind(source.src, source.type),
|
|
198
|
+
type: source.type,
|
|
199
|
+
quality: source.quality,
|
|
200
|
+
}
|
|
201
|
+
})
|
|
197
202
|
|
|
198
|
-
|
|
203
|
+
return {
|
|
204
|
+
sources,
|
|
205
|
+
signature,
|
|
206
|
+
dispose: () => {
|
|
199
207
|
for (const url of created) URL.revokeObjectURL(url)
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Piste active par défaut : celle marquée `default`, sinon la première avec des cues. */
|
|
213
|
+
function preferDefaultActiveSubtitle(
|
|
214
|
+
loaded: ResolvedSubtitleTrack[],
|
|
215
|
+
list: SubtitleTrack[],
|
|
216
|
+
): number | null {
|
|
217
|
+
const defaultIndex = list.findIndex((track) => track.default)
|
|
218
|
+
if (defaultIndex >= 0 && loaded[defaultIndex]?.cues.length) return defaultIndex
|
|
219
|
+
const firstWithCues = loaded.findIndex((track) => track.cues.length > 0)
|
|
220
|
+
return firstWithCues >= 0 ? firstWithCues : null
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function useResolvedSources(input: PlayerSource | PlayerSource[]) {
|
|
224
|
+
const signature = sourceSignature(input)
|
|
225
|
+
const [entry, setEntry] = useState<{ signature: string; resolver: SourceResolver }>(() => ({
|
|
226
|
+
signature,
|
|
227
|
+
resolver: resolveSources(input),
|
|
228
|
+
}))
|
|
229
|
+
|
|
230
|
+
// Ajustement au rendu quand la source change réellement (pas d'effet).
|
|
231
|
+
if (entry.signature !== signature) {
|
|
232
|
+
setEntry({ signature, resolver: resolveSources(input) })
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const resolver = entry.resolver
|
|
236
|
+
// Libère les object URLs du résolveur remplacé (celui du montage n'est pas
|
|
237
|
+
// libéré au démontage : StrictMode simulerait un unmount/remount).
|
|
238
|
+
const previousResolver = useRef(resolver)
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
if (previousResolver.current !== resolver) {
|
|
241
|
+
previousResolver.current.dispose()
|
|
242
|
+
previousResolver.current = resolver
|
|
200
243
|
}
|
|
201
|
-
}, [
|
|
244
|
+
}, [resolver])
|
|
202
245
|
|
|
203
|
-
return { sources, signature }
|
|
246
|
+
return { sources: resolver.sources, signature: resolver.signature }
|
|
204
247
|
}
|
|
205
248
|
|
|
206
249
|
export function VideoPlayer({
|
|
@@ -241,7 +284,7 @@ export function VideoPlayer({
|
|
|
241
284
|
onTimeUpdate,
|
|
242
285
|
onError,
|
|
243
286
|
ref,
|
|
244
|
-
}: VideoPlayerProps) {
|
|
287
|
+
}: Readonly<VideoPlayerProps>) {
|
|
245
288
|
const { sources, signature } = useResolvedSources(sourceInput)
|
|
246
289
|
|
|
247
290
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
@@ -254,12 +297,12 @@ export function VideoPlayer({
|
|
|
254
297
|
|
|
255
298
|
const [index, setIndex] = useState(0)
|
|
256
299
|
const [playing, setPlaying] = useState(false)
|
|
257
|
-
const [muted, setMuted] =
|
|
258
|
-
const [volume, setVolumeState] =
|
|
300
|
+
const [muted, setMuted] = useControllableValue<boolean>(mutedProp, mutedProp)
|
|
301
|
+
const [volume, setVolumeState] = useControllableValue<number>(volumeProp, clamp(volumeProp, 0, 1))
|
|
259
302
|
const [currentTime, setCurrentTime] = useState(0)
|
|
260
303
|
const [duration, setDuration] = useState(0)
|
|
261
304
|
const [buffered, setBuffered] = useState(0)
|
|
262
|
-
const [rate, setRate] =
|
|
305
|
+
const [rate, setRate] = useControllableValue<number>(playbackRate, playbackRate)
|
|
263
306
|
const [fullscreen, setFullscreen] = useState(false)
|
|
264
307
|
const [loading, setLoading] = useState(true)
|
|
265
308
|
const [error, setError] = useState<string | null>(null)
|
|
@@ -268,18 +311,43 @@ export function VideoPlayer({
|
|
|
268
311
|
const [embedActive, setEmbedActive] = useState(false)
|
|
269
312
|
const [embedPlaying, setEmbedPlaying] = useState(false)
|
|
270
313
|
const [embedSrc, setEmbedSrc] = useState<string | null>(null)
|
|
271
|
-
const [subtitleTracks, setSubtitleTracks] = useState<ResolvedSubtitleTrack[]>([])
|
|
272
|
-
const [activeSubtitle, setActiveSubtitle] = useState(-1)
|
|
273
314
|
const [openMenu, setOpenMenu] = useState<'speed' | 'quality' | 'subtitles' | null>(null)
|
|
274
|
-
const [
|
|
315
|
+
const [controlsHidden, setControlsHidden] = useState(false)
|
|
275
316
|
const [failedThumbKey, setFailedThumbKey] = useState<string | null>(null)
|
|
276
317
|
const [retryKey, setRetryKey] = useState(0)
|
|
318
|
+
const [prevSignature, setPrevSignature] = useState(signature)
|
|
319
|
+
|
|
320
|
+
// Ajustement au rendu : la source change ⇒ on repart d'un état propre,
|
|
321
|
+
// sans effet ni render supplémentaire.
|
|
322
|
+
if (prevSignature !== signature) {
|
|
323
|
+
setPrevSignature(signature)
|
|
324
|
+
setIndex(0)
|
|
325
|
+
setError(null)
|
|
326
|
+
setLoading(true)
|
|
327
|
+
setEmbedActive(false)
|
|
328
|
+
setEmbedPlaying(false)
|
|
329
|
+
setEmbedSrc(null)
|
|
330
|
+
setPlaying(false)
|
|
331
|
+
setCurrentTime(0)
|
|
332
|
+
setDuration(0)
|
|
333
|
+
setBuffered(0)
|
|
334
|
+
}
|
|
277
335
|
|
|
278
336
|
const subtitleSig = useMemo(
|
|
279
337
|
() => (subtitles ? subtitleSignature(subtitles) : ''),
|
|
280
338
|
[subtitles],
|
|
281
339
|
)
|
|
282
340
|
|
|
341
|
+
// Les pistes déjà chargées ne correspondent plus à la signature courante :
|
|
342
|
+
// on les écarte pendant le rechargement plutôt que par un effet de reset.
|
|
343
|
+
const [loadedSubtitles, setLoadedSubtitles] = useState<{
|
|
344
|
+
sig: string
|
|
345
|
+
tracks: ResolvedSubtitleTrack[]
|
|
346
|
+
} | null>(null)
|
|
347
|
+
const [activeSubtitle, setActiveSubtitle] = useState(-1)
|
|
348
|
+
const subtitleTracks =
|
|
349
|
+
loadedSubtitles && loadedSubtitles.sig === subtitleSig ? loadedSubtitles.tracks : []
|
|
350
|
+
|
|
283
351
|
const controlMode: VideoControlsMode =
|
|
284
352
|
controls === true ? 'full' : controls === false ? 'none' : controls
|
|
285
353
|
|
|
@@ -295,12 +363,13 @@ export function VideoPlayer({
|
|
|
295
363
|
: vimeoId(active.src)
|
|
296
364
|
: null
|
|
297
365
|
|
|
298
|
-
const
|
|
366
|
+
const observerSupported = typeof IntersectionObserver !== 'undefined'
|
|
367
|
+
const shouldLoad = inView || !lazy || !observerSupported
|
|
299
368
|
const playbackActive = isEmbed ? embedPlaying : playing
|
|
300
369
|
// L'iframe ne transmet pas les mouvements du pointeur : pour un embed, la barre
|
|
301
370
|
// reste affichée, sinon elle ne pourrait plus jamais être rappelée.
|
|
302
371
|
const showControls =
|
|
303
|
-
isEmbed ||
|
|
372
|
+
isEmbed || !controlsHidden || !playbackActive || openMenu !== null
|
|
304
373
|
|
|
305
374
|
const progress = duration > 0 ? clamp((currentTime / duration) * 100, 0, 100) : 0
|
|
306
375
|
const bufferedPercent = duration > 0 ? clamp((buffered / duration) * 100, 0, 100) : 0
|
|
@@ -347,32 +416,25 @@ export function VideoPlayer({
|
|
|
347
416
|
[embedKind, postEmbed],
|
|
348
417
|
)
|
|
349
418
|
|
|
350
|
-
// Dernier état voulu, réappliqué à chaque tentative de handshake : le lecteur
|
|
351
|
-
// de la plateforme ignore les commandes envoyées avant d'être prêt.
|
|
352
|
-
const embedSettings = useRef({ muted, volume, rate })
|
|
353
|
-
useEffect(() => {
|
|
354
|
-
embedSettings.current = { muted, volume, rate }
|
|
355
|
-
})
|
|
356
|
-
|
|
357
419
|
/** Abonne le lecteur aux événements de l'iframe (temps, durée, état, volume). */
|
|
358
420
|
const sendEmbedHandshake = useCallback(() => {
|
|
359
421
|
if (!embedKind) return
|
|
360
422
|
for (const message of embedHandshake(embedKind)) postEmbed(message)
|
|
361
423
|
|
|
362
|
-
|
|
363
|
-
postEmbedCommand({ type: '
|
|
364
|
-
postEmbedCommand({ type: '
|
|
365
|
-
|
|
366
|
-
}, [embedKind, postEmbed, postEmbedCommand])
|
|
424
|
+
postEmbedCommand({ type: 'mute', muted: muted })
|
|
425
|
+
postEmbedCommand({ type: 'volume', volume })
|
|
426
|
+
postEmbedCommand({ type: 'rate', rate })
|
|
427
|
+
}, [embedKind, postEmbed, postEmbedCommand, muted, volume, rate])
|
|
367
428
|
|
|
368
429
|
/** Monte l'iframe (façade → lecteur) : la lecture démarre avec le geste de l'utilisateur. */
|
|
369
430
|
const startEmbed = useCallback(() => {
|
|
370
431
|
if (!embedId || !embedKind) return
|
|
432
|
+
const effectiveMuted = muted ?? mutedProp
|
|
371
433
|
setEmbedSrc(
|
|
372
434
|
buildEmbedUrl(embedKind, embedId, {
|
|
373
435
|
startAt,
|
|
374
436
|
loop,
|
|
375
|
-
muted,
|
|
437
|
+
muted: effectiveMuted,
|
|
376
438
|
controls: false,
|
|
377
439
|
origin: window.location.origin.startsWith('http') ? window.location.origin : undefined,
|
|
378
440
|
}),
|
|
@@ -380,7 +442,7 @@ export function VideoPlayer({
|
|
|
380
442
|
setEmbedActive(true)
|
|
381
443
|
setEmbedPlaying(true)
|
|
382
444
|
setCurrentTime(startAt)
|
|
383
|
-
}, [embedId, embedKind, loop, muted, startAt])
|
|
445
|
+
}, [embedId, embedKind, loop, muted, mutedProp, startAt])
|
|
384
446
|
|
|
385
447
|
const toggleEmbedPlay = useCallback(() => {
|
|
386
448
|
// L'iframe n'est pas encore montée : on la lance au lieu d'envoyer une commande dans le vide.
|
|
@@ -444,7 +506,7 @@ export function VideoPlayer({
|
|
|
444
506
|
video.volume = next
|
|
445
507
|
video.muted = next === 0
|
|
446
508
|
}
|
|
447
|
-
}, [])
|
|
509
|
+
}, [setMuted, setVolumeState])
|
|
448
510
|
|
|
449
511
|
const toggleMute = useCallback(() => {
|
|
450
512
|
setMuted((previous) => {
|
|
@@ -453,7 +515,7 @@ export function VideoPlayer({
|
|
|
453
515
|
if (video) video.muted = next
|
|
454
516
|
return next
|
|
455
517
|
})
|
|
456
|
-
}, [])
|
|
518
|
+
}, [setMuted])
|
|
457
519
|
|
|
458
520
|
const changeRate = useCallback(
|
|
459
521
|
(value: number) => {
|
|
@@ -463,7 +525,7 @@ export function VideoPlayer({
|
|
|
463
525
|
if (isEmbed) postEmbedCommand({ type: 'rate', rate: value })
|
|
464
526
|
setOpenMenu(null)
|
|
465
527
|
},
|
|
466
|
-
[isEmbed, postEmbedCommand],
|
|
528
|
+
[isEmbed, postEmbedCommand, setRate],
|
|
467
529
|
)
|
|
468
530
|
|
|
469
531
|
const toggleFullscreen = useCallback(() => {
|
|
@@ -548,33 +610,21 @@ export function VideoPlayer({
|
|
|
548
610
|
requestFullscreen: () => toggleFullscreen(),
|
|
549
611
|
getVideo: () => videoRef.current,
|
|
550
612
|
}),
|
|
551
|
-
[changeRate, changeVolume, isEmbed, postEmbedCommand, seekTo, toggleFullscreen],
|
|
613
|
+
[changeRate, changeVolume, isEmbed, postEmbedCommand, seekTo, setMuted, toggleFullscreen],
|
|
552
614
|
)
|
|
553
615
|
|
|
554
616
|
/* ------------------------------------------------------------------ */
|
|
555
617
|
/* Synchronisation des props / de l'état */
|
|
556
618
|
/* ------------------------------------------------------------------ */
|
|
557
619
|
|
|
620
|
+
// La réinitialisation d'état se fait au rendu (voir ci-dessus) ; on ne garde
|
|
621
|
+
// ici que les refs, qui ne doivent pas être écrites pendant le rendu.
|
|
558
622
|
useEffect(() => {
|
|
559
|
-
setIndex(0)
|
|
560
623
|
startApplied.current = null
|
|
561
|
-
setError(null)
|
|
562
|
-
setLoading(true)
|
|
563
|
-
setEmbedActive(false)
|
|
564
|
-
setEmbedPlaying(false)
|
|
565
|
-
setEmbedSrc(null)
|
|
566
|
-
setPlaying(false)
|
|
567
|
-
setCurrentTime(0)
|
|
568
|
-
setDuration(0)
|
|
569
|
-
setBuffered(0)
|
|
570
624
|
}, [signature])
|
|
571
625
|
|
|
572
626
|
useEffect(() => {
|
|
573
|
-
if (!subtitles)
|
|
574
|
-
setSubtitleTracks([])
|
|
575
|
-
setActiveSubtitle(-1)
|
|
576
|
-
return
|
|
577
|
-
}
|
|
627
|
+
if (!subtitles) return
|
|
578
628
|
|
|
579
629
|
const list = toSubtitleList(subtitles)
|
|
580
630
|
const urls: string[] = []
|
|
@@ -600,10 +650,9 @@ export function VideoPlayer({
|
|
|
600
650
|
)
|
|
601
651
|
|
|
602
652
|
if (cancelled) return
|
|
603
|
-
|
|
604
|
-
const preferred = list.findIndex((track) => track.default)
|
|
653
|
+
setLoadedSubtitles({ sig: subtitleSig, tracks: loaded })
|
|
605
654
|
setActiveSubtitle(
|
|
606
|
-
|
|
655
|
+
preferDefaultActiveSubtitle(loaded, list) ?? -1,
|
|
607
656
|
)
|
|
608
657
|
}
|
|
609
658
|
|
|
@@ -613,15 +662,11 @@ export function VideoPlayer({
|
|
|
613
662
|
cancelled = true
|
|
614
663
|
for (const url of urls) URL.revokeObjectURL(url)
|
|
615
664
|
}
|
|
616
|
-
}, [subtitleSig])
|
|
617
|
-
|
|
618
|
-
useEffect(() => {
|
|
619
|
-
setMuted(mutedProp)
|
|
620
|
-
}, [mutedProp])
|
|
665
|
+
}, [subtitles, subtitleSig])
|
|
621
666
|
|
|
622
667
|
useEffect(() => {
|
|
623
668
|
if (!isEmbed || !embedActive) return
|
|
624
|
-
postEmbedCommand({ type: 'mute', muted })
|
|
669
|
+
postEmbedCommand({ type: 'mute', muted: muted })
|
|
625
670
|
}, [embedActive, isEmbed, muted, postEmbedCommand])
|
|
626
671
|
|
|
627
672
|
useEffect(() => {
|
|
@@ -661,42 +706,28 @@ export function VideoPlayer({
|
|
|
661
706
|
}
|
|
662
707
|
window.addEventListener('message', onMessage)
|
|
663
708
|
return () => window.removeEventListener('message', onMessage)
|
|
664
|
-
}, [embedActive, embedKind, isEmbed])
|
|
709
|
+
}, [embedActive, embedKind, isEmbed, setRate])
|
|
665
710
|
|
|
666
711
|
useEffect(() => {
|
|
667
712
|
if (embedKind !== 'vimeo' || !embedActive) return
|
|
668
713
|
postEmbed({ method: 'setLoop', value: loop })
|
|
669
714
|
}, [embedActive, embedKind, loop, postEmbed])
|
|
670
715
|
|
|
671
|
-
useEffect(() => {
|
|
672
|
-
setVolumeState(clamp(volumeProp, 0, 1))
|
|
673
|
-
}, [volumeProp])
|
|
674
|
-
|
|
675
|
-
useEffect(() => {
|
|
676
|
-
setRate(playbackRate)
|
|
677
|
-
}, [playbackRate])
|
|
678
|
-
|
|
679
716
|
useEffect(() => {
|
|
680
717
|
const video = videoRef.current
|
|
681
718
|
if (!video) return
|
|
682
|
-
video.volume = volume
|
|
683
|
-
video.muted = muted || volume === 0
|
|
684
|
-
}, [muted, volume])
|
|
719
|
+
video.volume = volume ?? volumeProp
|
|
720
|
+
video.muted = (muted ?? mutedProp) || (volume ?? volumeProp) === 0
|
|
721
|
+
}, [muted, mutedProp, volume, volumeProp])
|
|
685
722
|
|
|
686
723
|
useEffect(() => {
|
|
687
724
|
if (videoRef.current) videoRef.current.playbackRate = rate
|
|
688
725
|
}, [rate])
|
|
689
726
|
|
|
690
727
|
useEffect(() => {
|
|
691
|
-
if (!lazy)
|
|
692
|
-
setInView(true)
|
|
693
|
-
return
|
|
694
|
-
}
|
|
728
|
+
if (!lazy || !observerSupported) return
|
|
695
729
|
const element = containerRef.current
|
|
696
|
-
if (!element
|
|
697
|
-
setInView(true)
|
|
698
|
-
return
|
|
699
|
-
}
|
|
730
|
+
if (!element) return
|
|
700
731
|
const observer = new IntersectionObserver(
|
|
701
732
|
(entries) => {
|
|
702
733
|
const entry = entries[0]
|
|
@@ -714,11 +745,12 @@ export function VideoPlayer({
|
|
|
714
745
|
)
|
|
715
746
|
observer.observe(element)
|
|
716
747
|
return () => observer.disconnect()
|
|
717
|
-
}, [isEmbed, lazy, postEmbedCommand])
|
|
748
|
+
}, [isEmbed, lazy, observerSupported, postEmbedCommand])
|
|
718
749
|
|
|
719
750
|
useEffect(() => {
|
|
720
751
|
if (!autoPlay || kind === 'hls' || isEmbed || !shouldLoad) return
|
|
721
|
-
void attemptPlay()
|
|
752
|
+
const timer = window.setTimeout(() => void attemptPlay(), 0)
|
|
753
|
+
return () => window.clearTimeout(timer)
|
|
722
754
|
}, [attemptPlay, autoPlay, isEmbed, kind, shouldLoad, active?.src])
|
|
723
755
|
|
|
724
756
|
useEffect(() => {
|
|
@@ -735,31 +767,39 @@ export function VideoPlayer({
|
|
|
735
767
|
}, [playbackActive])
|
|
736
768
|
|
|
737
769
|
const scheduleHide = useCallback(() => {
|
|
738
|
-
|
|
770
|
+
setControlsHidden(false)
|
|
739
771
|
if (hideTimer.current) window.clearTimeout(hideTimer.current)
|
|
740
772
|
hideTimer.current = window.setTimeout(() => {
|
|
741
|
-
if (playbackActiveRef.current)
|
|
773
|
+
if (playbackActiveRef.current) setControlsHidden(true)
|
|
742
774
|
}, 2600)
|
|
743
775
|
}, [])
|
|
744
776
|
|
|
777
|
+
// À la reprise de lecture, les contrôles repartent visibles : ajustement au
|
|
778
|
+
// rendu plutôt qu'un setState synchrone dans l'effet.
|
|
779
|
+
const [prevPlaybackActive, setPrevPlaybackActive] = useState(playbackActive)
|
|
780
|
+
if (prevPlaybackActive !== playbackActive) {
|
|
781
|
+
setPrevPlaybackActive(playbackActive)
|
|
782
|
+
if (playbackActive) setControlsHidden(false)
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// L'effet ne fait qu'armer le minuteur (système externe) ; le reveal est
|
|
786
|
+
// géré par les événements pointeur ou par l'ajustement au rendu ci-dessus.
|
|
745
787
|
useEffect(() => {
|
|
746
788
|
if (!playbackActive) {
|
|
747
|
-
setControlsVisible(true)
|
|
748
789
|
if (hideTimer.current) {
|
|
749
790
|
window.clearTimeout(hideTimer.current)
|
|
750
791
|
hideTimer.current = null
|
|
751
792
|
}
|
|
752
793
|
return
|
|
753
794
|
}
|
|
754
|
-
|
|
795
|
+
if (hideTimer.current) window.clearTimeout(hideTimer.current)
|
|
796
|
+
hideTimer.current = window.setTimeout(() => {
|
|
797
|
+
if (playbackActiveRef.current) setControlsHidden(true)
|
|
798
|
+
}, 2600)
|
|
755
799
|
return () => {
|
|
756
800
|
if (hideTimer.current) window.clearTimeout(hideTimer.current)
|
|
757
801
|
}
|
|
758
|
-
}, [playbackActive
|
|
759
|
-
|
|
760
|
-
useEffect(() => {
|
|
761
|
-
if (openMenu) setControlsVisible(true)
|
|
762
|
-
}, [openMenu])
|
|
802
|
+
}, [playbackActive])
|
|
763
803
|
|
|
764
804
|
useEffect(() => {
|
|
765
805
|
if (!openMenu) return
|
|
@@ -885,7 +925,7 @@ export function VideoPlayer({
|
|
|
885
925
|
|
|
886
926
|
const handleEnded = () => {
|
|
887
927
|
setPlaying(false)
|
|
888
|
-
|
|
928
|
+
setControlsHidden(false)
|
|
889
929
|
onEnded?.()
|
|
890
930
|
}
|
|
891
931
|
|
|
@@ -949,7 +989,7 @@ export function VideoPlayer({
|
|
|
949
989
|
|
|
950
990
|
const handlePointerActivity = () => {
|
|
951
991
|
if (!playbackActive) {
|
|
952
|
-
|
|
992
|
+
setControlsHidden(false)
|
|
953
993
|
return
|
|
954
994
|
}
|
|
955
995
|
scheduleHide()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Retourne un état local piloté par une prop quand celle-ci est explicite.
|
|
5
|
+
*
|
|
6
|
+
* Quand le parent fournit explicitement la prop, l'état local se
|
|
7
|
+
* synchronise dessus. Quand la prop est `undefined`, le composant garde
|
|
8
|
+
* sa propre valeur locale.
|
|
9
|
+
*
|
|
10
|
+
* Cette version est compatible avec React Compiler : elle n'accède jamais
|
|
11
|
+
* aux refs pendant le rendu.
|
|
12
|
+
*/
|
|
13
|
+
export function useControllableValue<T>(
|
|
14
|
+
prop: T | undefined,
|
|
15
|
+
defaultState: T,
|
|
16
|
+
onChange?: (value: T) => void,
|
|
17
|
+
) {
|
|
18
|
+
const [state, setState] = useState<T>(defaultState)
|
|
19
|
+
const controlledRef = useRef<boolean>(prop !== undefined)
|
|
20
|
+
const previousPropRef = useRef<T | undefined>(prop)
|
|
21
|
+
|
|
22
|
+
const isControlled = prop !== undefined
|
|
23
|
+
|
|
24
|
+
// Synchronise l'état local quand le contrôle parent change.
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
controlledRef.current = isControlled
|
|
27
|
+
previousPropRef.current = prop
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// Premier rendu contrôlé : la prop doit remplacer l'état local.
|
|
31
|
+
// Passages ultérieurs : synchronise seulement si la prop a changé.
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!isControlled) return
|
|
34
|
+
const next = prop as T
|
|
35
|
+
if (next !== previousPropRef.current) setState(next)
|
|
36
|
+
}, [isControlled, prop, state])
|
|
37
|
+
|
|
38
|
+
const value = isControlled ? (prop as T) : state
|
|
39
|
+
|
|
40
|
+
const setValue = useCallback(
|
|
41
|
+
(next: T | ((previous: T) => T)) => {
|
|
42
|
+
const resolved = typeof next === 'function' ? (next as (p: T) => T)(state) : next
|
|
43
|
+
if (isControlled) {
|
|
44
|
+
if (resolved !== prop) onChange?.(resolved)
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
setState(resolved)
|
|
48
|
+
onChange?.(resolved)
|
|
49
|
+
},
|
|
50
|
+
[isControlled, onChange, prop, state],
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return [value, setValue] as const
|
|
54
|
+
}
|
package/src/lib/video.ts
CHANGED
|
@@ -70,29 +70,29 @@ export function youtubeThumbnail(id: string): string {
|
|
|
70
70
|
|
|
71
71
|
export function youtubeThumbnailFallback(id: string): string {
|
|
72
72
|
return `https://i.ytimg.com/vi/${id}/hqdefault.jpg`
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export interface EmbedOptions {
|
|
73
|
+
}export interface EmbedOptions {
|
|
76
74
|
startAt?: number
|
|
77
75
|
loop?: boolean
|
|
78
76
|
muted?: boolean
|
|
79
77
|
controls?: boolean
|
|
80
78
|
/** Origine de la page hôte : l'API iframe YouTube lui associe les commandes postMessage. */
|
|
81
79
|
origin?: string
|
|
80
|
+
playsinline?: boolean
|
|
82
81
|
}
|
|
83
82
|
|
|
83
|
+
|
|
84
84
|
export function buildEmbedUrl(
|
|
85
85
|
kind: 'youtube' | 'vimeo',
|
|
86
86
|
id: string,
|
|
87
87
|
options: EmbedOptions = {},
|
|
88
88
|
): string {
|
|
89
|
-
const { startAt = 0, loop = false, muted = false, controls = true, origin } = options
|
|
89
|
+
const { startAt = 0, loop = false, muted = false, controls = true, origin, playsinline = true } = options
|
|
90
90
|
|
|
91
91
|
if (kind === 'youtube') {
|
|
92
92
|
const params = new URLSearchParams({
|
|
93
93
|
autoplay: '1',
|
|
94
94
|
rel: '0',
|
|
95
|
-
playsinline: '1',
|
|
95
|
+
playsinline: playsinline ? '1' : '0',
|
|
96
96
|
modestbranding: '1',
|
|
97
97
|
enablejsapi: '1',
|
|
98
98
|
controls: controls ? '1' : '0',
|
|
@@ -121,6 +121,7 @@ export function buildEmbedUrl(
|
|
|
121
121
|
return `https://player.vimeo.com/video/${id}?${params.toString()}${hash}`
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
|
|
124
125
|
/* -------------------------------------------------------------------------- */
|
|
125
126
|
/* Pilotage des embeds (YouTube / Vimeo) par postMessage */
|
|
126
127
|
/* -------------------------------------------------------------------------- */
|