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.
@@ -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
- function useResolvedSources(input: PlayerSource | PlayerSource[]) {
167
- const signature = useMemo(() => sourceSignature(input), [input])
168
-
169
- const [sources, setSources] = useState<ResolvedSource[]>([])
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
- // La signature résume `input` : l'effet ne tourne que lorsqu'une source change réellement.
172
- useEffect(() => {
173
- const created: string[] = []
174
- const next = toSourceList(input).map<ResolvedSource>((source) => {
175
- if (typeof source === 'string') {
176
- return { src: source, kind: detectKind(source) }
177
- }
178
- if (source instanceof Blob) {
179
- const url = URL.createObjectURL(source)
180
- created.push(url)
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: source.src,
190
- kind: detectKind(source.src, source.type),
191
- type: source.type,
192
- quality: source.quality,
189
+ src: url,
190
+ kind: 'native',
191
+ objectUrl: true,
192
+ type: source.type || undefined,
193
193
  }
194
- })
195
-
196
- setSources(next)
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
- return () => {
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
- }, [signature])
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] = useState(mutedProp)
258
- const [volume, setVolumeState] = useState(clamp(volumeProp, 0, 1))
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] = useState(playbackRate)
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 [controlsVisible, setControlsVisible] = useState(true)
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 shouldLoad = inView || !lazy
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 || controlsVisible || !playbackActive || openMenu !== null
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
- const settings = embedSettings.current
363
- postEmbedCommand({ type: 'mute', muted: settings.muted })
364
- postEmbedCommand({ type: 'volume', volume: settings.volume })
365
- postEmbedCommand({ type: 'rate', rate: settings.rate })
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
- setSubtitleTracks(loaded)
604
- const preferred = list.findIndex((track) => track.default)
653
+ setLoadedSubtitles({ sig: subtitleSig, tracks: loaded })
605
654
  setActiveSubtitle(
606
- preferred >= 0 && loaded[preferred] ? preferred : loaded.length > 0 ? 0 : -1,
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 || typeof IntersectionObserver === 'undefined') {
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
- setControlsVisible(true)
770
+ setControlsHidden(false)
739
771
  if (hideTimer.current) window.clearTimeout(hideTimer.current)
740
772
  hideTimer.current = window.setTimeout(() => {
741
- if (playbackActiveRef.current) setControlsVisible(false)
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
- scheduleHide()
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, scheduleHide])
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
- setControlsVisible(true)
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
- setControlsVisible(true)
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
  /* -------------------------------------------------------------------------- */