uifolio 0.3.0 → 0.4.0

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/cli/uifolio.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  * uifolio list liste des composants disponibles
8
8
  * uifolio init [--cwd dir] écrit uifolio.json (config cible)
9
9
  * uifolio add <noms...> [options] copie les composants + helpers
10
+ * --page <chemin> crée ou adapte la page cible
10
11
  *
11
12
  * Options :
12
13
  * --cwd <dir> projet cible (défaut : répertoire courant)
@@ -16,11 +17,14 @@
16
17
  * --color <hex> intègre cette couleur aux fichiers copiés (plus de --primary)
17
18
  * --radius <px> écrit --radius dans uifolio-theme.css
18
19
  * --force écrase les fichiers existants
20
+ * --page <chemin> chemin de la page (ex : src/pages/calendar.tsx)
21
+ * crée la page si elle n'existe pas, sinon y ajoute
22
+ * l'import et un appel de composant
19
23
  * --dry-run affiche ce qui serait copié sans rien écrire
20
24
  *
21
25
  * Exemples :
22
26
  * node cli/uifolio.mjs add calendar otp --cwd ../mon-projet
23
- * node cli/uifolio.mjs add calendar --color "#10B981"
27
+ * node cli/uifolio.mjs add calendar --color "#10B981" --page src/pages/calendar.tsx
24
28
  * node cli/uifolio.mjs add video-player --alias "#lib" --dry-run
25
29
  */
26
30
 
@@ -120,6 +124,96 @@ function externalDeps(source) {
120
124
  return [...deps]
121
125
  }
122
126
 
127
+ /**
128
+ * Liste les pages cx (fichiers .tsx dans src/pages/) du projet cible.
129
+ */
130
+ function listPagesInCwd(cwd) {
131
+ if (!cwd || typeof cwd !== 'string') return []
132
+ try {
133
+ const cwdPath = resolve(cwd)
134
+ const srcRoot = join(cwdPath, 'src')
135
+ if (!existsSync(srcRoot)) return []
136
+ const pagesDir = join(srcRoot, 'pages')
137
+ if (!existsSync(pagesDir)) return []
138
+ const entries = readdirSync(pagesDir)
139
+ if (!Array.isArray(entries)) return []
140
+ return entries
141
+ .filter((name) => typeof name === 'string' && name.endsWith('.tsx'))
142
+ .map((name) => join('src', 'pages', name))
143
+ .sort()
144
+ } catch {
145
+ return []
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Demande interactivement à l'utilisateur quelle page utiliser, ou retourne
151
+ * null si l'entrée n'est pas valide / la saisie est vide.
152
+ */
153
+ function choosePageInteraction(pages, cwd) {
154
+ if (!Array.isArray(pages) || pages.length === 0) return null
155
+ if (pages.length === 1) {
156
+ info(`Une page existe déjà : ${pages[0]} (utilisée par défaut)`)
157
+ try {
158
+ return join(resolve(cwd), pages[0])
159
+ } catch {
160
+ return null
161
+ }
162
+ }
163
+ let cwdPath = cwd
164
+ try {
165
+ cwdPath = resolve(cwd)
166
+ } catch {
167
+ cwdPath = process.cwd()
168
+ }
169
+
170
+ console.log('\nPlusieurs pages existent dans src/pages/. Choisis celle où importer le/des composant(s) :')
171
+ pages.forEach((p, index) => {
172
+ console.log(` ${index + 1}. ${p}`)
173
+ })
174
+ console.log(' 0. Ne pas importer dans une page')
175
+
176
+ const answer = promptPageChoice()
177
+ if (answer === null) {
178
+ info('Aucune page sélectionnée : les composants seront copiés sans être importés dans une page.')
179
+ return null
180
+ }
181
+ if (answer === 0) {
182
+ info('Import dans une page annulé.')
183
+ return null
184
+ }
185
+ const selected = pages[answer - 1]
186
+ if (!selected) {
187
+ info('Choix invalide : aucune page sélectionnée.')
188
+ return null
189
+ }
190
+ info(`Page retenue : ${selected}`)
191
+ try {
192
+ return join(cwdPath, selected)
193
+ } catch {
194
+ return null
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Lecture synchrone d'une ligne depuis stdin (pour l'interaction CLI).
200
+ * Retourne null en cas d'erreur de lecture ou d'EOF.
201
+ */
202
+ function promptPageChoice() {
203
+ const chunk = Buffer.alloc(1024)
204
+ try {
205
+ const n = process.stdin.read(0, chunk.length, chunk)
206
+ if (n <= 0) return null
207
+ const text = chunk.toString('utf8', 0, n).trim()
208
+ if (text === '') return null
209
+ const parsed = Number(text)
210
+ if (!Number.isInteger(parsed)) return null
211
+ return parsed
212
+ } catch {
213
+ return null
214
+ }
215
+ }
216
+
123
217
  /**
124
218
  * Résolution transitive : un composant importe des helpers (@/lib/…)
125
219
  * qui peuvent eux-mêmes importer d'autres fichiers locaux.
@@ -275,12 +369,28 @@ function commandInit({ flags }) {
275
369
  info('Puis : uifolio add video-player calendar …')
276
370
  }
277
371
 
372
+
278
373
  function commandAdd({ positional, flags }) {
279
374
  const names = positional.map((name) => ALIASES[name] ?? name)
375
+
280
376
  if (names.length === 0) fail('Aucun composant demandé. Ex. : uifolio add calendar otp')
281
377
 
282
378
  const cwd = resolve(flags.cwd ?? process.cwd())
283
379
  const config = readConfig(cwd) ?? {}
380
+ const pageFlag = typeof flags.page === 'string' ? flags.page : undefined
381
+ let pageTarget = pageFlag ? resolve(cwd, pageFlag) : null
382
+
383
+ // Détermine la page cible effective : --page explicite, choix interactif, ou aucune.
384
+ if (!pageTarget) {
385
+ const pages = listPagesInCwd(cwd)
386
+ if (pages.length > 0) {
387
+ const chosen = choosePageInteraction(pages, cwd)
388
+ if (chosen) pageTarget = chosen
389
+ }
390
+ }
391
+
392
+ const createPage = Boolean(pageTarget) && !existsSync(pageTarget)
393
+ let pageImports = []
284
394
 
285
395
  const componentsDir = typeof flags.dir === 'string' ? flags.dir : config.dir ?? 'src/components/ui'
286
396
  const libDir = typeof flags.lib === 'string' ? flags.lib : config.lib ?? 'src/lib'
@@ -300,11 +410,26 @@ function commandAdd({ positional, flags }) {
300
410
  }
301
411
 
302
412
  if (!dryRun && !existsSync(cwd)) fail(`Répertoire cible introuvable : ${cwd}`)
413
+ if (pageTarget && !existsSync(pageTarget) && !dryRun) {
414
+ const bad = pageFlag ?? relative(cwd, pageTarget)
415
+ fail(`Page cible introuvable : ${bad}.`)
416
+ }
417
+ if (pageTarget && !existsSync(pageTarget) && dryRun) {
418
+ info(`[dry-run] page cible introuvable : ${pageFlag ?? relative(cwd, pageTarget)} (simulation uniquement).`)
419
+ pageTarget = null
420
+ }
303
421
 
304
422
  const deps = new Set(LIB_DEPS)
305
423
  const written = new Set()
306
424
  const skipped = []
307
425
 
426
+ // Prépare les imports qui seront injectés dans la page cible.
427
+ for (const name of names) {
428
+ if (!pageTarget) continue
429
+ const importPath = relative(cwd, join(componentsDir, `${name}.tsx`))
430
+ pageImports.push({ name, importPath })
431
+ }
432
+
308
433
  for (const name of names) {
309
434
  const entry = join(REGISTRY_UI, `${name}.tsx`)
310
435
  const files = collectFiles(entry, REGISTRY_SRC)
@@ -358,6 +483,45 @@ function commandAdd({ positional, flags }) {
358
483
  info(`Pense à aligner l'alias ${targetAlias} dans ton tsconfig/vite (remplace de @/).`)
359
484
  }
360
485
  console.log('')
486
+ } // Crée ou adapte la page cible si une page cible a été sélectionnée.
487
+ if (pageTarget) {
488
+ const dir = dirname(pageTarget)
489
+ if (createPage) {
490
+ if (!dryRun) {
491
+ mkdirSync(dir, { recursive: true })
492
+ writeFileSync(
493
+ pageTarget,
494
+ `import { ${pageImports.map((p) => p.name).join(', ')} } from '${relative(dir, join(cwd, componentsDir)).replace(/\\/g, '/')}'\n'\n` +
495
+ `export default function Page() {\n` +
496
+ ` return (<>\n` +
497
+ pageImports.map((p) => ` <${p.name} />`).join('\n') +
498
+ `\n </>)\n` +
499
+ `}\n`,
500
+ 'utf8',
501
+ )
502
+ }
503
+ ok(`${dryRun ? '[dry-run] ' : ''}Page créée : ${relative(cwd, pageTarget)}`)
504
+ for (const p of pageImports) {
505
+ info(`· <${p.name} /> importé depuis ${relative(cwd, join(componentsDir, `${p.name}.tsx`))}`)
506
+ }
507
+ } else {
508
+ if (dryRun) {
509
+ info(`[dry-run] page existante détectée : ${relative(cwd, pageTarget)}`)
510
+ } else {
511
+ const source = readFileSync(pageTarget, 'utf8')
512
+ const rendered =
513
+ pageImports.map((p) => `import { ${p.name} } from '${relative(dirname(pageTarget), join(cwd, componentsDir)).replace(/\\/g, '/')}';`).join('\n')
514
+ const functionPoint = source.indexOf('function ')
515
+ const insertPoint =
516
+ functionPoint !== -1 ? functionPoint : source.lastIndexOf('</')
517
+ const updatedSource =
518
+ insertPoint === -1
519
+ ? source + '\n' + rendered + '\n' + pageImports.map((p) => `<${p.name} />`).join('\n')
520
+ : source.slice(0, insertPoint) + '\n' + rendered + '\n' + source.slice(insertPoint)
521
+ writeFileSync(pageTarget, updatedSource, 'utf8')
522
+ }
523
+ ok(`Page existante mise à jour : ${relative(cwd, pageTarget)}`)
524
+ }
361
525
  }
362
526
  }
363
527
 
@@ -382,17 +546,37 @@ Options :
382
546
  --radius <px> arrondi global (0–24) dans uifolio-theme.css
383
547
  --force écrase les fichiers existants
384
548
  --dry-run simule sans écrire
549
+ --page <chemin> page cible (ex : src/pages/calendar.tsx). Avec --page, la CLI crée la page
550
+ si elle n'existe pas, ou y ajoute l'import + l'appel du/des composant(s).
551
+ Sans --page et quand des pages existent déjà dans src/pages, la CLI demande
552
+ interactivement laquelle utiliser avant d'importer.
385
553
 
386
554
  Exemples :
387
555
  node cli/uifolio.mjs add video-player calendar otp --cwd ../mon-projet
388
556
  node cli/uifolio.mjs add otp --color "#10B981" --radius 8
389
557
  node cli/uifolio.mjs add otp --alias "#lib" --dry-run
558
+ node cli/uifolio.mjs add calendar --page src/pages/calendar.tsx
390
559
  `)
391
560
  }
392
561
 
393
562
  const { positional, flags } = parseArgs(process.argv.slice(2))
394
563
  const command = positional.shift()
395
564
 
565
+ // Requête interactive de la page cible avant toute écriture, sauf si --page est déjà passé.
566
+ if (command === 'add' && typeof flags.page !== 'string') {
567
+ let cwd
568
+ try {
569
+ cwd = resolve(flags.cwd ?? process.cwd())
570
+ } catch {
571
+ cwd = process.cwd()
572
+ }
573
+ const pages = listPagesInCwd(cwd)
574
+ if (pages.length > 0) {
575
+ const target = choosePageInteraction(pages, cwd)
576
+ if (target) flags.page = target
577
+ }
578
+ }
579
+
396
580
  switch (command) {
397
581
  case 'list':
398
582
  commandList()
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "uifolio",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "CLI qui copie des composants React (calendrier, lecteurs vidéo/audio, OTP, éditeur, upload) et leurs helpers dans ton projet, avec des tokens Tailwind CSS 4.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Barronakn <jeanbarronalokpon@gmail.com>",
8
- "homepage": "https://github.com/Barronakn/UIfolio#readme",
8
+ "homepage": "https://ui.folio.barrondev.site",
9
9
  "repository": {
10
10
  "type": "git",
11
11
  "url": "git+https://github.com/Barronakn/UIfolio.git"
@@ -14,17 +14,32 @@
14
14
  "url": "https://github.com/Barronakn/UIfolio/issues"
15
15
  },
16
16
  "keywords": [
17
- "uifolio",
18
17
  "react",
19
- "components",
20
- "ui",
21
- "cli",
22
- "tailwind",
18
+ "react-component",
23
19
  "tailwindcss",
20
+ "ui-components",
21
+ "component-library",
22
+ "cli",
23
+ "uifolio",
24
24
  "calendar",
25
25
  "video-player",
26
+ "audio-player",
26
27
  "otp",
27
- "shadcn"
28
+ "color-picker",
29
+ "file-upload",
30
+ "rich-text-editor",
31
+ "command-menu",
32
+ "resizable-panels",
33
+ "time-picker",
34
+ "cadence",
35
+ "storytelling",
36
+ "accordion",
37
+ "text-reveal",
38
+ "hls.js",
39
+ "vtt",
40
+ "shadcn",
41
+ "shadcn-clone",
42
+ "shadcn-like"
28
43
  ],
29
44
  "engines": {
30
45
  "node": ">=18"
@@ -41,6 +56,7 @@
41
56
  "src/lib/color.ts",
42
57
  "src/lib/date.ts",
43
58
  "src/lib/uploader.ts",
59
+ "src/lib/use-controllable.ts",
44
60
  "src/lib/use-hotkeys.ts",
45
61
  "src/lib/use-reduced-motion.ts",
46
62
  "src/lib/use-theme.ts",
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from 'react'
1
+ import { useEffect, useRef, useState } from 'react'
2
2
  import { Search, X, Check, ChevronDown } from 'lucide-react'
3
3
  import { cn } from '@/lib/utils'
4
4
 
@@ -155,20 +155,25 @@ export function IconGlyph({
155
155
  className?: string
156
156
  }) {
157
157
  const [data, setData] = useState<IconData | null>(() => iconCache.get(icon) ?? null)
158
+ const [prevIcon, setPrevIcon] = useState(icon)
159
+ const loadRef = useRef<Promise<void> | null>(null)
160
+
161
+ // Ajustement pendant le rendu : une icône déjà en cache s'applique sans effet.
162
+ if (prevIcon !== icon) {
163
+ setPrevIcon(icon)
164
+ setData(iconCache.get(icon) ?? null)
165
+ }
158
166
 
159
167
  useEffect(() => {
160
- const cached = iconCache.get(icon)
161
- if (cached) {
162
- setData(cached)
163
- return
164
- }
165
- let cancelled = false
166
- setData(null)
167
- loadIcons([icon]).then((icons) => {
168
- if (!cancelled) setData(icons[icon] ?? null)
168
+ if (iconCache.get(icon)) return
169
+ const current = loadIcons([icon]).then((icons) => {
170
+ if (loadRef.current === current) {
171
+ setData(icons[icon] ?? null)
172
+ }
169
173
  })
174
+ loadRef.current = current
170
175
  return () => {
171
- cancelled = true
176
+ loadRef.current = null
172
177
  }
173
178
  }, [icon])
174
179
 
@@ -187,6 +192,7 @@ export function IconPicker({
187
192
  const [icons, setIcons] = useState<string[]>([])
188
193
  const [bodies, setBodies] = useState<Record<string, IconData>>({})
189
194
  const [loading, setLoading] = useState(false)
195
+ const loadingRef = useRef<Promise<void> | null>(null)
190
196
 
191
197
  useEffect(() => {
192
198
  if (!open) return
@@ -221,12 +227,14 @@ export function IconPicker({
221
227
 
222
228
  useEffect(() => {
223
229
  if (icons.length === 0) return
224
- let cancelled = false
225
- loadIcons(icons).then((loaded) => {
226
- if (!cancelled) setBodies((prev) => ({ ...prev, ...loaded }))
230
+ const current = loadIcons(icons).then((loaded) => {
231
+ if (loadingRef.current === current) {
232
+ setBodies((prev) => ({ ...prev, ...loaded }))
233
+ }
227
234
  })
235
+ loadingRef.current = current
228
236
  return () => {
229
- cancelled = true
237
+ loadingRef.current = null
230
238
  }
231
239
  }, [icons])
232
240
 
@@ -42,11 +42,10 @@ export function OtpInput({
42
42
  }: OtpInputProps) {
43
43
  const [internalValue, setInternalValue] = useState(defaultValue)
44
44
  const current = value ?? internalValue
45
- const [phase, setPhase] = useState<'idle' | 'success'>('idle')
45
+ const complete = current.length === length && /^\d+$/.test(current)
46
+ const phase = complete && !error ? 'success' : 'idle'
46
47
 
47
48
  const inputs = useRef<Array<HTMLInputElement | null>>([])
48
- const completed = useRef(false)
49
- const completedValue = useRef<string | null>(null)
50
49
  const onChangeRef = useRef(onChange)
51
50
  const onCompleteRef = useRef(onComplete)
52
51
 
@@ -55,39 +54,16 @@ export function OtpInput({
55
54
  onCompleteRef.current = onComplete
56
55
  })
57
56
 
57
+ useEffect(() => {
58
+ if (autoFocus) inputs.current[0]?.focus()
59
+ }, [autoFocus])
60
+
58
61
  const commit = (next: string) => {
59
62
  const cleaned = next.replace(/\D/g, '').slice(0, length)
60
63
  if (value === undefined) setInternalValue(cleaned)
61
64
  onChangeRef.current?.(cleaned)
62
65
  }
63
66
 
64
- const complete = current.length === length && /^\d+$/.test(current)
65
-
66
- useEffect(() => {
67
- if (!complete) {
68
- completed.current = false
69
- completedValue.current = null
70
- setPhase('idle')
71
- return
72
- }
73
- if (completed.current && completedValue.current === current) return
74
- completed.current = true
75
- completedValue.current = current
76
- setPhase('success')
77
- onCompleteRef.current?.(current)
78
- }, [complete, current, length])
79
-
80
- useEffect(() => {
81
- if (!error) return
82
- completed.current = false
83
- completedValue.current = null
84
- setPhase('idle')
85
- }, [error])
86
-
87
- useEffect(() => {
88
- if (autoFocus) inputs.current[0]?.focus()
89
- }, [autoFocus])
90
-
91
67
  const focusInput = (index: number) => {
92
68
  inputs.current[Math.max(0, Math.min(length - 1, index))]?.focus()
93
69
  inputs.current[Math.max(0, Math.min(length - 1, index))]?.select()
@@ -0,0 +1,18 @@
1
+ const accentToScheme: Record<string, { dark: string; light: string; foreground: string; background: string }> = {
2
+ '#10b981': {
3
+ dark: '#0f172a',
4
+ light: '#0f172a',
5
+ foreground: '#f1f5f9',
6
+ background: '#ecfdf5',
7
+ },
8
+ default: {
9
+ dark: '#f8fafc',
10
+ light: '#0f172a',
11
+ foreground: '#0f172a',
12
+ background: '#ffffff',
13
+ },
14
+ }
15
+
16
+ export function accentTokens(accent: string) {
17
+ return accentToScheme[accent] ?? accentToScheme.default
18
+ }
@@ -50,6 +50,11 @@ export function Stories({
50
50
 
51
51
  const setOpen = useCallback(
52
52
  (next: boolean) => {
53
+ if (next) {
54
+ // Réinitialise la lecture à l'ouverture plutôt que par un effet.
55
+ elapsedRef.current = 0
56
+ setPaused(false)
57
+ }
53
58
  if (open === undefined) setInternalOpen(next)
54
59
  onOpenChange?.(next)
55
60
  },
@@ -76,13 +81,6 @@ export function Stories({
76
81
 
77
82
  const close = useCallback(() => setOpen(false), [setOpen])
78
83
 
79
- useEffect(() => {
80
- if (!isOpen) return
81
- setIndex(initialIndex)
82
- setPaused(false)
83
- elapsedRef.current = 0
84
- }, [initialIndex, isOpen])
85
-
86
84
  useEffect(() => {
87
85
  if (!isOpen) return
88
86
  const previous = document.body.style.overflow
@@ -115,14 +113,19 @@ export function Stories({
115
113
  else void video.play().catch(() => {})
116
114
  }, [index, isOpen, paused])
117
115
 
116
+ const mainLoopRef = useRef<{ startedAt: number; total: number } | null>(null)
117
+
118
118
  useEffect(() => {
119
119
  if (!isOpen || paused) return
120
120
  if (barRef.current) barRef.current.style.transform = 'scaleX(0)'
121
121
 
122
- const total = items[index]?.duration ?? duration
122
+ const item = items[index]
123
+ const total = item?.duration ?? duration
123
124
  const startedAt = performance.now() - elapsedRef.current
124
- let frame = 0
125
125
 
126
+ mainLoopRef.current = { startedAt, total }
127
+
128
+ let frame = 0
126
129
  const tick = (now: number) => {
127
130
  const elapsed = now - startedAt
128
131
  elapsedRef.current = elapsed
@@ -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({
@@ -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
  /* -------------------------------------------------------------------------- */