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 +185 -1
- package/package.json +24 -8
- package/src/components/ui/icon-picker.tsx +23 -15
- package/src/components/ui/otp-input.tsx +6 -30
- package/src/components/ui/stories-tokens.ts +18 -0
- package/src/components/ui/stories.tsx +12 -9
- package/src/components/ui/video-player.tsx +156 -116
- package/src/lib/use-controllable.ts +54 -0
- package/src/lib/video.ts +6 -5
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
|
+
"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://
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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({
|
|
@@ -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
|
/* -------------------------------------------------------------------------- */
|