{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "player",
  "title": "Video Player",
  "description": "Le lecteur vidéo complet : socle, contrôles, chapitres et sous-titres.",
  "dependencies": [
    "cn",
    "shaka-player",
    "lucide-react"
  ],
  "registryDependencies": [
    "@shadcn/button"
  ],
  "files": [
    {
      "path": "registry/videocn/player-engine.ts",
      "content": "\"use client\";\n\n/**\n * Le lecteur ne parle jamais directement à un moteur de streaming : tout passe\n * par cette interface. Elle est implémentée une première fois en natif —\n * `<video src>` seul, pour les MP4 et WebM progressifs — et le sera une seconde\n * fois par Shaka quand la source est HLS ou DASH.\n *\n * L'API publique du lecteur ne varie pas d'un moteur à l'autre ; seules les\n * capacités varient, et elles sont **observables** plutôt que figées : Shaka\n * découvre les pistes après le chargement et fait varier la liste des qualités\n * en cours de lecture. Un objet rendu une fois pour toutes ne pourrait pas le\n * refléter.\n */\n\n/** Le type de source servie, pas l'implémentation qui la sert. */\nexport type SourceType = \"native\" | \"hls\" | \"dash\";\n\n/**\n * Le chargement d'un moteur est asynchrone — `await import(\"shaka-player\")` —\n * donc distinct du buffering. `loading` couvre l'intervalle entre le montage et\n * le moment où la lecture est possible, pendant lequel le poster reste affiché.\n */\nexport type EngineStatus = \"idle\" | \"loading\" | \"ready\" | \"error\";\n\nexport interface QualityLevel {\n  /** Identifiant opaque, propre au moteur. */\n  id: string;\n  height: number;\n  bitrate: number | null;\n  /** Prêt à afficher, « 1080p ». */\n  label: string;\n}\n\nexport interface EngineCapabilities {\n  /**\n   * Vide avec le moteur natif : le navigateur ne dit pas ce qu'il y a dans un\n   * MP4 progressif. Le sélecteur de qualité reste alors affiché et passe\n   * `disabled`, comme sur YouTube — un contrôle qui disparaît déroute plus\n   * qu'un contrôle grisé.\n   */\n  readonly qualities: readonly QualityLevel[];\n  /** `null` quand la sélection est automatique, c'est-à-dire adaptative. */\n  readonly activeQualityId: string | null;\n  /**\n   * Ce qui est réellement joué en ce moment, sélection automatique comprise.\n   * C'est ce qui permet d'écrire « Auto (720p) » : en adaptatif, `activeQualityId`\n   * vaut `null` et ne dit rien de l'image qu'on est en train de regarder.\n   */\n  readonly playingQualityId: string | null;\n  readonly isLive: boolean;\n}\n\nexport const NO_CAPABILITIES: EngineCapabilities = Object.freeze({\n  qualities: Object.freeze([]),\n  activeQualityId: null,\n  playingQualityId: null,\n  isLive: false,\n});\n\n/**\n * Les quatre premiers codes sont ceux de `MediaError`, le dernier couvre ce qui\n * casse avant la vidéo elle-même : moteur introuvable, manifeste illisible.\n */\nexport type PlayerErrorCode = \"aborted\" | \"network\" | \"decode\" | \"unsupported\" | \"engine\";\n\nexport interface PlayerError {\n  code: PlayerErrorCode;\n  message: string;\n}\n\nexport interface PlayerEngine {\n  /** Le type de source, une fois la détection et la prop `type` résolues. */\n  readonly source: SourceType;\n  /** Reçoit l'élément avant tout `load`. */\n  attach(video: HTMLVideoElement): void;\n  /**\n   * Résout quand la source est prise en charge par le moteur — la balise a sa\n   * source en natif, le manifeste est analysé avec Shaka. Ce n'est pas le\n   * moment où la lecture devient possible : ça, ce sont les événements de\n   * `<video>` qui le disent.\n   */\n  load(src: string): Promise<void>;\n  /**\n   * Libère l'élément. **Peut rendre une promesse** : Shaka détache l'élément et\n   * démonte `MediaSource` de façon asynchrone, et tant que ce n'est pas fini,\n   * l'élément ne peut pas être confié à un autre moteur. Le lecteur attend donc\n   * cette promesse avant de brancher le suivant — sans quoi un simple\n   * changement de `src` laisse la balise muette.\n   */\n  destroy(): void | Promise<void>;\n  getCapabilities(): EngineCapabilities;\n  /** `null` rend la sélection automatique. Sans effet si `qualities` est vide. */\n  selectQuality(id: string | null): void;\n  /** Prévient d'un changement de capacités. Renvoie le désabonnement. */\n  subscribe(listener: () => void): () => void;\n}\n",
      "type": "registry:ui",
      "target": "@ui/video-player/player-engine.ts"
    },
    {
      "path": "registry/videocn/native-engine.ts",
      "content": "\"use client\";\n\nimport { NO_CAPABILITIES } from \"./player-engine\";\nimport type { PlayerEngine, SourceType } from \"./player-engine\";\n\n/**\n * Le moteur du cas le plus fréquent : la balise `<video>` toute seule. Il ne\n * fait rien de plus que poser une source, et c'est exactement ce qu'on lui\n * demande — aucun JavaScript de streaming, rien ajouté au bundle.\n */\nexport function createNativeEngine(source: SourceType): PlayerEngine {\n  let video: HTMLVideoElement | null = null;\n  const listeners = new Set<() => void>();\n\n  return {\n    source,\n\n    attach(element) {\n      video = element;\n    },\n\n    load(src) {\n      if (!video) {\n        return Promise.reject(\n          new Error(\"The native engine has no element: call attach() before load().\"),\n        );\n      }\n      video.src = src;\n      // `load()` force le navigateur à reprendre l'algorithme de sélection de\n      // ressource ; sans lui, réécrire `src` sur un élément déjà chargé peut\n      // rester sans effet.\n      video.load();\n      // La source est posée, donc le contrat de `load` est rempli. La lecture,\n      // elle, n'est possible que plus tard : ce sont les événements de\n      // `<video>` qui le disent, pas cette promesse.\n      return Promise.resolve();\n    },\n\n    destroy() {\n      if (video) {\n        // Retirer l'attribut puis recharger est le seul moyen de faire lâcher\n        // le flux au navigateur. `video.src = \"\"` déclencherait une requête\n        // vers l'URL de la page.\n        video.removeAttribute(\"src\");\n        video.load();\n      }\n      video = null;\n      listeners.clear();\n    },\n\n    /*\n     * Les trois méthodes qui suivent sont inertes ici, et c'est structurel :\n     * le navigateur ne dit rien du contenu d'un MP4 progressif. Pas de liste de\n     * pistes, donc rien à sélectionner, et rien qui puisse changer en cours de\n     * lecture — d'où un `subscribe` qui enregistre sans jamais émettre.\n     *\n     * C'est le moteur Shaka de la phase 4 qui les remplira : il découvre les\n     * qualités après l'analyse du manifeste, les fait varier en adaptatif et\n     * connaît le live. Les abonnés écrits aujourd'hui contre cette interface\n     * se réveilleront tout seuls ce jour-là.\n     */\n\n    getCapabilities() {\n      return NO_CAPABILITIES;\n    },\n\n    selectQuality() {},\n\n    subscribe(listener) {\n      listeners.add(listener);\n      return () => {\n        listeners.delete(listener);\n      };\n    },\n  };\n}\n",
      "type": "registry:ui",
      "target": "@ui/video-player/native-engine.ts"
    },
    {
      "path": "registry/videocn/shaka-engine.ts",
      "content": "\"use client\";\n\nimport type shaka from \"shaka-player\";\n\nimport { createNativeEngine } from \"./native-engine\";\nimport { NO_CAPABILITIES } from \"./player-engine\";\nimport type { EngineCapabilities, PlayerEngine, QualityLevel, SourceType } from \"./player-engine\";\n\n/**\n * Le moteur des sources adaptatives — HLS et DASH. Shaka n'est jamais importé\n * au chargement de ce module : il l'est à l'intérieur de `load()`, et c'est\n * toute la raison d'être de ce découpage. Un projet qui ne sert que des MP4\n * progressifs ne verra pas passer une seule ligne de Shaka dans son bundle.\n */\n\n/** L'espace de noms Shaka tel qu'il arrive de l'import dynamique. */\ntype ShakaNamespace = (typeof import(\"shaka-player\"))[\"default\"];\n\n/**\n * Mémorisé au niveau du module, pas de la fabrique : deux lecteurs sur la même\n * page partagent un seul téléchargement et une seule installation des\n * polyfills, qui sont globaux par nature.\n */\nlet shakaModule: Promise<ShakaNamespace> | null = null;\n\nfunction importShaka(): Promise<ShakaNamespace> {\n  shakaModule ??= import(\"shaka-player\").then((mod) => {\n    // Le paquet est compilé façon Closure : selon le bundler et le format de\n    // sortie, l'espace de noms arrive en export par défaut ou directement comme\n    // objet du module. On accepte les deux plutôt que de parier sur l'interop.\n    const namespace: ShakaNamespace = mod.default ?? (mod as unknown as ShakaNamespace);\n    // Les polyfills alignent EME, `MediaSource` et `VTTCue` d'un navigateur à\n    // l'autre. Ils s'installent globalement, une fois pour la page.\n    namespace.polyfill.installAll();\n    return namespace;\n  });\n  return shakaModule;\n}\n\n/**\n * Au-delà, une même hauteur mérite sa propre entrée : « 1080p » et « 1080p60 »\n * ne se valent pas à l'œil, et personne ne veut choisir entre deux lignes\n * identiques.\n */\nconst HIGH_FRAME_RATE = 30;\n\n/**\n * Ce qu'on garde devant la tête de lecture en changeant de qualité.\n *\n * Tout vider fait apparaître la nouvelle qualité dans la seconde sur un\n * navigateur de bureau — deux cents millisecondes mesurées —, mais laisse la\n * balise sans une seule image à jouer le temps que le premier segment arrive.\n * Sur iPhone, où `ManagedMediaSource` gouverne le tampon, elle reste alors\n * figée sur la dernière image et ne repart pas. La documentation de Shaka le\n * dit sans détour : en dessous de deux segments, le changement « provoque des\n * hoquets sur certains navigateurs ».\n *\n * On garde donc un segment, mesuré sur le flux lui-même plutôt que supposé, et\n * borné : deux secondes au moins pour qu'il reste quelque chose à jouer,\n * quatre au plus pour que le changement se voie encore comme une réponse au\n * clic. Un flux à segments de dix secondes ne fera donc pas attendre dix\n * secondes.\n */\nconst MIN_SWITCH_MARGIN = 2;\nconst MAX_SWITCH_MARGIN = 4;\n\nfunction switchSafeMargin(player: shaka.Player): number {\n  const { maxSegmentDuration } = player.getStats();\n  const segment =\n    Number.isFinite(maxSegmentDuration) && maxSegmentDuration > 0\n      ? maxSegmentDuration\n      : MIN_SWITCH_MARGIN;\n  return Math.min(Math.max(segment, MIN_SWITCH_MARGIN), MAX_SWITCH_MARGIN);\n}\n\n/**\n * Le libellé sert aussi de clé de regroupement, et c'est voulu : deux pistes\n * qui s'afficheraient pareil sont la même entrée pour l'utilisateur, quel que\n * soit leur débit. La cadence n'entre dans la clé que par « haute ou non »,\n * pour ne pas éclater le menu sur des 59,94 contre 60.\n */\nfunction qualityLabel(height: number, frameRate: number | null): string {\n  return (frameRate ?? 0) > HIGH_FRAME_RATE ? `${height}p60` : `${height}p`;\n}\n\nfunction buildCapabilities(player: shaka.Player, abrEnabled: boolean): EngineCapabilities {\n  const tracks = player.getVariantTracks();\n\n  const byLabel = new Map<string, QualityLevel>();\n  for (const track of tracks) {\n    const { height } = track;\n    // Sans hauteur, il n'y a rien à afficher : piste audio seule, ou manifeste\n    // qui ne déclare pas ses dimensions.\n    if (height === null) continue;\n    const label = qualityLabel(height, track.frameRate);\n    const kept = byLabel.get(label);\n    // Entre deux variantes qui s'affichent pareil, on garde la mieux dotée :\n    // c'est celle qu'on attend en demandant « 1080p » explicitement.\n    if (kept && (kept.bitrate ?? 0) >= track.bandwidth) continue;\n    byLabel.set(label, { id: String(track.id), height, bitrate: track.bandwidth, label });\n  }\n\n  const qualities = [...byLabel.values()].sort(\n    (a, b) => b.height - a.height || (b.bitrate ?? 0) - (a.bitrate ?? 0),\n  );\n\n  // La piste réellement jouée n'est pas forcément celle qu'on a retenue pour sa\n  // hauteur : en adaptatif, Shaka descend volontiers sur une variante de même\n  // hauteur et de débit moindre. On la ramène donc à l'entrée qui la\n  // représente, sans quoi le menu n'aurait rien à cocher.\n  const playing = tracks.find((track) => track.active && track.height !== null);\n  const playingHeight = playing?.height ?? null;\n  const playingQualityId =\n    playing && playingHeight !== null\n      ? (byLabel.get(qualityLabel(playingHeight, playing.frameRate))?.id ?? null)\n      : null;\n\n  return {\n    qualities,\n    // En adaptatif, rien n'est « choisi » : c'est ce `null` qui fait cocher\n    // « Auto », pendant que `playingQualityId` remplit la parenthèse.\n    activeQualityId: abrEnabled ? null : playingQualityId,\n    playingQualityId,\n    isLive: player.isLive(),\n  };\n}\n\nfunction sameQualities(a: readonly QualityLevel[], b: readonly QualityLevel[]): boolean {\n  if (a.length !== b.length) return false;\n  return a.every((quality, index) => {\n    const other = b[index];\n    return (\n      quality.id === other.id &&\n      quality.height === other.height &&\n      quality.bitrate === other.bitrate &&\n      quality.label === other.label\n    );\n  });\n}\n\n/**\n * `getCapabilities()` est lu par un store React, et l'adaptatif fait parler le\n * moteur à chaque segment. Renvoyer un objet neuf à chaque fois re-rendrait\n * tous les contrôles en continu : on ne remplace la valeur mémorisée que\n * lorsqu'elle a réellement changé.\n */\nfunction sameCapabilities(a: EngineCapabilities, b: EngineCapabilities): boolean {\n  return (\n    a.activeQualityId === b.activeQualityId &&\n    a.playingQualityId === b.playingQualityId &&\n    a.isLive === b.isLive &&\n    sameQualities(a.qualities, b.qualities)\n  );\n}\n\n/** La forme d'une `shaka.util.Error`, reconnue sans dépendre du module. */\ninterface ShakaErrorLike {\n  severity: number;\n  category: number;\n  code: number;\n}\n\nfunction isShakaError(cause: unknown): cause is ShakaErrorLike {\n  if (typeof cause !== \"object\" || cause === null) return false;\n  const candidate = cause as Partial<ShakaErrorLike>;\n  return typeof candidate.category === \"number\" && typeof candidate.code === \"number\";\n}\n\n/**\n * `use-player` transforme tout rejet de `load()` en `PlayerError` de code\n * `engine` et n'en garde que le message : c'est donc ici, et nulle part\n * ailleurs, qu'il faut le rendre lisible. Le couple catégorie/code de Shaka y\n * reste : c'est la seule prise pour diagnostiquer un manifeste qui refuse.\n */\nfunction engineErrorMessage(namespace: ShakaNamespace | null, cause: unknown): string {\n  if (isShakaError(cause)) {\n    if (namespace && cause.category === namespace.util.Error.Category.NETWORK) {\n      return `The manifest or a segment could not be downloaded (network error ${cause.code}).`;\n    }\n    return `This source could not be played (Shaka error ${cause.category}.${cause.code}).`;\n  }\n  return cause instanceof Error ? cause.message : \"The video engine could not load the source.\";\n}\n\nexport function createShakaEngine(source: SourceType): PlayerEngine {\n  let video: HTMLVideoElement | null = null;\n  let player: shaka.Player | null = null;\n  /** Le moteur natif, quand Shaka ne peut pas tourner du tout sur ce navigateur. */\n  let fallback: PlayerEngine | null = null;\n  let unsubscribeFallback: (() => void) | null = null;\n  let destroyed = false;\n  /**\n   * Shaka démarre en adaptatif. On suit notre propre drapeau plutôt que de\n   * relire la configuration : `getConfiguration()` en clone l'intégralité, et\n   * cette valeur est lue à chaque changement de piste.\n   */\n  let abrEnabled = true;\n  let capabilities: EngineCapabilities = NO_CAPABILITIES;\n  const listeners = new Set<() => void>();\n  /** Le rejet du `load()` en cours, tant qu'il y en a un. */\n  let failLoad: ((cause: unknown) => void) | null = null;\n\n  const notify = () => {\n    for (const listener of listeners) listener();\n  };\n\n  const refresh = () => {\n    if (!player) return;\n    const next = buildCapabilities(player, abrEnabled);\n    if (sameCapabilities(capabilities, next)) return;\n    capabilities = next;\n    notify();\n  };\n\n  return {\n    source,\n\n    attach(element) {\n      video = element;\n    },\n\n    async load(src) {\n      const element = video;\n      if (!element) {\n        throw new Error(\"The Shaka engine has no element: call attach() before load().\");\n      }\n\n      let namespace: ShakaNamespace | null = null;\n      try {\n        namespace = await importShaka();\n        // Le composant a pu se démonter pendant l'import : il n'y a alors plus\n        // rien à charger, et rien qui ait échoué non plus.\n        if (destroyed) return;\n\n        if (!namespace.Player.isBrowserSupported()) {\n          // Ni MSE ni Managed Media Source : Shaka ne peut pas tourner ici. On\n          // rend la main à la balise plutôt que d'échouer — elle lit le HLS\n          // nativement là où ça arrive, et un lecteur qui joue vaudra toujours\n          // mieux qu'un lecteur qui explique pourquoi il ne joue pas.\n          const native = createNativeEngine(source);\n          fallback = native;\n          unsubscribeFallback = native.subscribe(notify);\n          native.attach(element);\n          await native.load(src);\n          return;\n        }\n\n        // Le constructeur accepte encore l'élément, mais c'est déprécié : la\n        // forme vivante est `attach()`, qui rend la main quand l'élément est\n        // réellement pris en charge.\n        const instance = new namespace.Player();\n        player = instance;\n\n        // Shaka ne gère jamais le texte. Les sous-titres et les chapitres\n        // passent par `<track>` et l'API `TextTrack`, identiquement dans les\n        // deux moteurs ; le laisser faire ferait apparaître les pistes en\n        // double sur HLS et pas du tout sur MP4, soit l'inverse exact de la\n        // transparence visée.\n        instance.configure(\"manifest.disableText\", true);\n\n        instance.addEventListener(\"trackschanged\", refresh);\n        instance.addEventListener(\"variantchanged\", refresh);\n        instance.addEventListener(\"adaptation\", refresh);\n        const critical = namespace.util.Error.Severity.CRITICAL;\n        instance.addEventListener(\"error\", (event: Event) => {\n          const detail = (event as Event & { detail?: unknown }).detail;\n          // Shaka émet aussi les erreurs qu'il a su rattraper — un segment\n          // retéléchargé, une clé rejouée. Les remonter ferait échouer une\n          // lecture qui repart toute seule.\n          if (isShakaError(detail) && detail.severity !== critical) return;\n          // Pendant le chargement, c'est ce canal qui porte la panne : on\n          // rejette `load()`, que `use-player` traduit en erreur de moteur.\n          // Après, `failLoad` est nul et on s'arrête là — ce qui interrompt\n          // réellement la lecture finit sur l'élément `<video>`, que\n          // `use-player` écoute déjà. Un canal de plus ne dirait rien de neuf.\n          failLoad?.(detail ?? event);\n        });\n\n        await instance.attach(element);\n        if (destroyed) return;\n\n        // `load()` rejette de lui-même sur un manifeste illisible, mais pas sur\n        // tout : une erreur émise en parallèle doit pouvoir couper court plutôt\n        // que de laisser le poster tourner indéfiniment.\n        const failure = new Promise<never>((_, reject) => {\n          failLoad = reject;\n        });\n        await Promise.race([instance.load(src), failure]);\n        if (destroyed) return;\n\n        refresh();\n      } catch (cause) {\n        // Un démontage en cours de route fait rejeter `attach()` ou `load()` :\n        // c'est attendu, et ce n'est pas une panne à remonter.\n        if (destroyed) return;\n        throw new Error(engineErrorMessage(namespace, cause));\n      } finally {\n        failLoad = null;\n      }\n    },\n\n    destroy() {\n      destroyed = true;\n      failLoad = null;\n      listeners.clear();\n      capabilities = NO_CAPABILITIES;\n\n      video = null;\n\n      unsubscribeFallback?.();\n      unsubscribeFallback = null;\n      const native = fallback;\n      fallback = null;\n      const instance = player;\n      player = null;\n\n      // Le moteur natif rend déjà l'élément propre : rien à ajouter derrière.\n      if (native) {\n        native.destroy();\n        return;\n      }\n\n      // Démontage pendant l'import dynamique : il n'y a jamais eu de lecteur, et\n      // Shaka n'a donc rien posé sur la balise. Rien à nettoyer.\n      if (!instance) return;\n\n      // On laisse `destroy()` faire, et **on ne touche plus à la balise\n      // ensuite** : il détache l'élément et libère `MediaSource` lui-même.\n      //\n      // Sa promesse est rendue au lecteur, qui attendra avant de brancher le\n      // moteur suivant. Les deux moitiés de cette règle ont été mesurées :\n      // nettoyer la balise après coup la vidait sous le moteur déjà en place\n      // (`emptied`, `readyState 0`), et attacher le suivant sans attendre le\n      // laissait bloqué sur « chargement », sans erreur, indéfiniment.\n      return instance.destroy().then(\n        () => undefined,\n        () => undefined,\n      );\n    },\n\n    getCapabilities() {\n      return fallback ? fallback.getCapabilities() : capabilities;\n    },\n\n    selectQuality(id) {\n      if (fallback) {\n        fallback.selectQuality(id);\n        return;\n      }\n      const instance = player;\n      if (!instance) return;\n\n      if (id === null) {\n        abrEnabled = true;\n        instance.configure(\"abr.enabled\", true);\n        refresh();\n        return;\n      }\n\n      const track = instance.getVariantTracks().find((candidate) => String(candidate.id) === id);\n      // Identifiant inconnu — piste disparue d'un manifeste live, menu en\n      // retard d'un rafraîchissement : on ne fait rien plutôt que de couper\n      // l'adaptatif pour une piste qui n'existe plus.\n      if (!track) return;\n\n      abrEnabled = false;\n      instance.configure(\"abr.enabled\", false);\n      // `clearBuffer` à vrai : le changement doit se voir tout de suite, comme\n      // sur YouTube. Sans lui, la nouvelle qualité n'arrive qu'une fois épuisé\n      // ce qui est déjà téléchargé — plusieurs dizaines de secondes de retard.\n      // La marge, elle, évite de laisser la balise sans image : voir\n      // `switchSafeMargin`.\n      instance.selectVariantTrack(track, true, switchSafeMargin(instance));\n      refresh();\n    },\n\n    subscribe(listener) {\n      listeners.add(listener);\n      return () => {\n        listeners.delete(listener);\n      };\n    },\n  };\n}\n",
      "type": "registry:ui",
      "target": "@ui/video-player/shaka-engine.ts"
    },
    {
      "path": "registry/videocn/resolve-engine.ts",
      "content": "\"use client\";\n\nimport { createNativeEngine } from \"./native-engine\";\nimport type { PlayerEngine, SourceType } from \"./player-engine\";\nimport { createShakaEngine } from \"./shaka-engine\";\n\n/**\n * Le seul endroit où un moteur est choisi, et le seul qui connaisse les\n * implémentations. `player-engine.ts` reste un contrat sans aucun import :\n * c'est ce qui permet à chaque moteur de l'importer sans fermer de cycle.\n */\n\n/**\n * Détection par l'extension. Elle se trompe sur les URL signées, sans extension\n * ou trompeuses : c'est à ça que sert la prop d'échappement `type`, qui la\n * court-circuite.\n */\nexport function detectSourceType(src: string): SourceType {\n  const path = src.split(/[?#]/)[0].toLowerCase();\n  if (path.endsWith(\".m3u8\")) return \"hls\";\n  if (path.endsWith(\".mpd\")) return \"dash\";\n  return \"native\";\n}\n\n/**\n * Le HLS et le DASH passent par Shaka — adaptatif, live et DVR, et surtout\n * l'absorption des divergences MSE d'un navigateur à l'autre. Tout le reste\n * demeure sur la balise seule : Shaka sait retomber sur `src=` là où MSE\n * manque, ce n'est pas une raison de lui faire passer les fichiers\n * progressifs, qui n'ont besoin d'aucun JavaScript de streaming.\n */\nexport function resolveEngine(src: string, type?: SourceType): PlayerEngine {\n  const source = type ?? detectSourceType(src);\n  return source === \"native\" ? createNativeEngine(source) : createShakaEngine(source);\n}\n",
      "type": "registry:ui",
      "target": "@ui/video-player/resolve-engine.ts"
    },
    {
      "path": "registry/videocn/playhead-store.ts",
      "content": "\"use client\";\n\nimport { LIVE_EDGE_TOLERANCE } from \"./controls-options\";\n\n/**\n * La tête de lecture ne passe pas par l'état React.\n *\n * `timeupdate` n'est émis que quatre fois par seconde : la valeur est juste,\n * mais une barre qui n'avance qu'à ce rythme saute par paliers. Interpoler\n * entre deux événements reviendrait à *deviner* la position — la barre\n * continuerait d'avancer pendant que la vidéo cale pour charger, et à contre-\n * temps dès qu'on passe en 2×. On lit donc la vraie valeur à chaque frame.\n *\n * Soixante fois par seconde dans l'état d'un contexte React re-rendrait tout le\n * lecteur, bouton play et curseur de volume compris. D'où ce store : la valeur\n * vit dans une clôture, et seuls les composants qui s'y abonnent — le scrubber,\n * l'horodatage — se re-rendent. `useSyncExternalStore` s'y branche directement.\n */\n\nexport interface PlayheadSnapshot {\n  /**\n   * Au bord du direct, ou aussi près qu'on puisse l'être de ce flux-là. Décidé\n   * ici et nulle part ailleurs : la pastille, l'horodatage et le scrubber\n   * doivent en dire la même chose, et un booléen ne les réveille qu'aux\n   * bascules au lieu d'une fois par frame.\n   *\n   * Sans objet en vidéo à la demande, où le bord n'est que la fin du fichier :\n   * les contrôles ne le lisent qu'en direct.\n   */\n  readonly atLiveEdge: boolean;\n  readonly currentTime: number;\n  /**\n   * Bornes de la plage chargée qui contient la tête de lecture, ce que le\n   * scrubber dessine en aperçu du buffer. Le début compte : après un saut à\n   * 7:00, la plage part de 7:00, et la dessiner depuis zéro mentirait. Les\n   * autres plages — celles d'avant le saut — ne le concernent pas.\n   */\n  readonly bufferedStart: number;\n  readonly bufferedEnd: number;\n  /**\n   * La fenêtre où l'on a le droit de chercher. En vidéo à la demande, elle va\n   * de zéro à la durée ; en direct, c'est la fenêtre encore diffusée, et elle\n   * glisse en permanence.\n   *\n   * Lue sur `video.seekable` et non sur le moteur : elle est alors juste avec\n   * Shaka comme avec le HLS natif d'un iPhone qui n'a pas de Shaka du tout.\n   */\n  readonly seekableStart: number;\n  readonly seekableEnd: number;\n  /**\n   * La position demandée pendant un glissement, `null` le reste du temps. Elle\n   * vit ici et non dans le curseur parce que plusieurs choses doivent suivre le\n   * doigt plutôt que la vidéo — le scrubber, l'horodatage, le texte lu par un\n   * lecteur d'écran — et qu'elles lisent toutes ce store.\n   */\n  readonly scrubTime: number | null;\n}\n\nexport interface PlayheadStore {\n  subscribe(listener: () => void): () => void;\n  getSnapshot(): PlayheadSnapshot;\n  getServerSnapshot(): PlayheadSnapshot;\n  /** Branche le store sur l'élément. Renvoie le débranchement. */\n  attach(video: HTMLVideoElement): () => void;\n  /**\n   * Pose la position d'aperçu d'un glissement, ou la retire avec `null`. Au\n   * retrait, l'élément est relu dans le même mouvement : la recherche finale\n   * l'a déjà positionné, donc rien ne revient en arrière à l'écran.\n   */\n  scrub(time: number | null): void;\n}\n\n/** Ce qu'il faut afficher : le doigt s'il y en a un, sinon la vidéo. */\nexport function displayedTime(snapshot: PlayheadSnapshot): number {\n  return snapshot.scrubTime ?? snapshot.currentTime;\n}\n\nconst EMPTY_PLAYHEAD: PlayheadSnapshot = Object.freeze({\n  atLiveEdge: true,\n  currentTime: 0,\n  bufferedStart: 0,\n  bufferedEnd: 0,\n  seekableStart: 0,\n  seekableEnd: 0,\n  scrubTime: null,\n});\n\n/**\n * Tolérance de raccord : les navigateurs laissent des trous de quelques\n * millisecondes entre deux plages contiguës, qu'il ne faut pas lire comme une\n * interruption du buffer.\n */\nconst RANGE_TOLERANCE = 0.25;\n\n/**\n * La fenêtre cherchable, de la première borne à la dernière. Plusieurs plages,\n * c'est un flux troué : on garde l'enveloppe, qui est ce que le scrubber\n * dessine. Aucune plage — les métadonnées manquent encore — donne une fenêtre\n * vide posée sur `time`, jamais `0 → 0`, qui ferait sauter la tête de lecture.\n */\nfunction seekableWindow(video: HTMLVideoElement, time: number): [number, number] {\n  const { seekable } = video;\n  if (seekable.length === 0) return [time, time];\n  return [seekable.start(0), seekable.end(seekable.length - 1)];\n}\n\n/** La plage qui contient `time`, ou une plage vide posée sur `time`. */\nfunction bufferedRangeAt(video: HTMLVideoElement, time: number): [number, number] {\n  const { buffered } = video;\n  for (let i = 0; i < buffered.length; i += 1) {\n    if (time >= buffered.start(i) - RANGE_TOLERANCE && time <= buffered.end(i)) {\n      return [buffered.start(i), buffered.end(i)];\n    }\n  }\n  return [time, time];\n}\n\nexport function createPlayheadStore(): PlayheadStore {\n  const listeners = new Set<() => void>();\n  let snapshot: PlayheadSnapshot = EMPTY_PLAYHEAD;\n  let video: HTMLVideoElement | null = null;\n  let frame = 0;\n  /**\n   * Le retard de croisière du flux : le plus petit écart au bord jamais observé\n   * depuis le chargement. Mesuré et non supposé, comme les capacités du moteur\n   * — il vaut trois secondes sur un flux à basse latence et une trentaine sur\n   * un HLS classique, et c'est par rapport à **lui** qu'on juge si l'on est au\n   * bord. `Infinity` tant qu'on n'a rien vu : tout écart fait alors référence.\n   */\n  let liveLatency = Number.POSITIVE_INFINITY;\n\n  /**\n   * `getSnapshot` doit renvoyer la même référence tant que rien ne change,\n   * sans quoi `useSyncExternalStore` re-rend en boucle. On ne reconstruit\n   * l'objet que sur un vrai changement de valeur — et c'est le seul endroit où\n   * il est reconstruit, que la cause soit la vidéo ou le doigt.\n   */\n  function commit(next: PlayheadSnapshot) {\n    if (\n      next.currentTime === snapshot.currentTime &&\n      next.bufferedStart === snapshot.bufferedStart &&\n      next.bufferedEnd === snapshot.bufferedEnd &&\n      next.seekableStart === snapshot.seekableStart &&\n      next.seekableEnd === snapshot.seekableEnd &&\n      next.atLiveEdge === snapshot.atLiveEdge &&\n      next.scrubTime === snapshot.scrubTime\n    ) {\n      return;\n    }\n    snapshot = next;\n    for (const listener of listeners) listener();\n  }\n\n  function measure(scrubTime: number | null): PlayheadSnapshot | null {\n    if (!video) return null;\n    const currentTime = video.currentTime;\n    const [bufferedStart, bufferedEnd] = bufferedRangeAt(video, currentTime);\n    const [seekableStart, seekableEnd] = seekableWindow(video, currentTime);\n\n    // La référence se prend sur la vidéo et non sur le doigt : pendant un\n    // glissement, la position affichée n'est pas celle qu'on lit.\n    if (!video.paused) {\n      liveLatency = Math.min(liveLatency, seekableEnd - currentTime);\n    }\n    // Le doigt, lui, décide de l'affichage : la pastille s'éteint dès qu'on\n    // quitte le bord, sans attendre que la vidéo ait cherché.\n    const delay = seekableEnd - (scrubTime ?? currentTime);\n    const atLiveEdge = delay <= liveLatency + LIVE_EDGE_TOLERANCE;\n\n    return {\n      atLiveEdge,\n      currentTime,\n      bufferedStart,\n      bufferedEnd,\n      seekableStart,\n      seekableEnd,\n      scrubTime,\n    };\n  }\n\n  function read() {\n    const next = measure(snapshot.scrubTime);\n    if (next) commit(next);\n  }\n\n  function tick() {\n    read();\n    frame = requestAnimationFrame(tick);\n  }\n\n  function startTicking() {\n    // La boucle ne tourne qu'entre `play` et `pause` : à l'arrêt, la tête ne\n    // bouge pas et une frame toutes les seize millisecondes ne servirait qu'à\n    // chauffer. Elle continue pendant un calage, où la tête peut encore avancer\n    // de quelques images avant de s'immobiliser ; `read()` sort tôt quand la\n    // valeur n'a pas changé, donc ça ne coûte rien.\n    if (frame === 0) frame = requestAnimationFrame(tick);\n  }\n\n  function stopTicking() {\n    if (frame !== 0) cancelAnimationFrame(frame);\n    frame = 0;\n  }\n\n  function reset() {\n    // Le retard de croisière appartient au flux qu'on quitte : le suivant aura\n    // le sien, et garder l'ancien ferait juger le nouveau sur une référence\n    // qui n'est pas la sienne.\n    liveLatency = Number.POSITIVE_INFINITY;\n    if (snapshot === EMPTY_PLAYHEAD) return;\n    snapshot = EMPTY_PLAYHEAD;\n    for (const listener of listeners) listener();\n  }\n\n  return {\n    subscribe(listener) {\n      listeners.add(listener);\n      return () => {\n        listeners.delete(listener);\n      };\n    },\n    getSnapshot() {\n      return snapshot;\n    },\n    // Rien à lire au rendu serveur : la tête de lecture démarre à zéro.\n    getServerSnapshot() {\n      return EMPTY_PLAYHEAD;\n    },\n    scrub(time) {\n      const scrubTime = time === null || !Number.isFinite(time) ? null : time;\n      commit(measure(scrubTime) ?? { ...snapshot, scrubTime });\n    },\n    attach(element) {\n      video = element;\n      read();\n      if (!element.paused) startTicking();\n\n      element.addEventListener(\"play\", startTicking);\n      element.addEventListener(\"playing\", startTicking);\n      element.addEventListener(\"pause\", stopTicking);\n      element.addEventListener(\"ended\", stopTicking);\n      // Hors lecture, ces événements sont la seule source de fraîcheur — et\n      // `timeupdate` prend le relais quand l'onglet passe en arrière-plan, où\n      // le navigateur suspend `requestAnimationFrame`.\n      element.addEventListener(\"timeupdate\", read);\n      element.addEventListener(\"seeking\", read);\n      element.addEventListener(\"seeked\", read);\n      element.addEventListener(\"progress\", read);\n      element.addEventListener(\"loadedmetadata\", read);\n      element.addEventListener(\"emptied\", reset);\n\n      return () => {\n        stopTicking();\n        element.removeEventListener(\"play\", startTicking);\n        element.removeEventListener(\"playing\", startTicking);\n        element.removeEventListener(\"pause\", stopTicking);\n        element.removeEventListener(\"ended\", stopTicking);\n        element.removeEventListener(\"timeupdate\", read);\n        element.removeEventListener(\"seeking\", read);\n        element.removeEventListener(\"seeked\", read);\n        element.removeEventListener(\"progress\", read);\n        element.removeEventListener(\"loadedmetadata\", read);\n        element.removeEventListener(\"emptied\", reset);\n        video = null;\n        reset();\n      };\n    },\n  };\n}\n",
      "type": "registry:ui",
      "target": "@ui/video-player/playhead-store.ts"
    },
    {
      "path": "registry/videocn/player-state-store.ts",
      "content": "\"use client\";\n\nimport { NO_CAPABILITIES } from \"./player-engine\";\nimport type { EngineCapabilities, EngineStatus, PlayerError } from \"./player-engine\";\n\n/**\n * Tout l'état du lecteur **sauf la tête de lecture**, qui a son propre store\n * dans `playhead-store.ts` parce qu'elle avance à chaque frame.\n *\n * Pourquoi un store ici aussi, et pas un `useState` : cet état est distribué à\n * tous les contrôles, et un `useState` ne se distribue que par contexte. Or un\n * contexte porte **une seule valeur** — la changer re-rend tous ceux qui la\n * lisent, même ceux dont le champ n'a pas bougé. Mesuré sur la barre : un\n * glissement de volume re-rendait le bouton plein écran et le bouton\n * Picture-in-Picture vingt et une fois, et `memo` n'y pouvait rien puisque le\n * re-rendu vient du contexte et non des props.\n *\n * Avec un store, chaque contrôle lit **sa** tranche par `useSyncExternalStore`,\n * et React ne le re-rend que si cette tranche a changé. Deuxième bénéfice : on\n * ne recopie plus un système extérieur dans un état React depuis un effet, ce\n * que React déconseille — un élément `<video>` est justement un système\n * extérieur, et `useSyncExternalStore` est l'outil prévu pour s'y abonner.\n */\n\nexport interface PlayerState {\n  paused: boolean;\n  ended: boolean;\n  /**\n   * La vidéo veut avancer et n'a pas de quoi. Distinct de\n   * `engineStatus: \"loading\"`, qui décrit le moteur et non le flux : l'un se\n   * résout une fois, l'autre peut revenir à chaque trou de réseau.\n   */\n  isBuffering: boolean;\n  duration: number;\n  volume: number;\n  muted: boolean;\n  playbackRate: number;\n  canControlVolume: boolean;\n  canFullscreen: boolean;\n  isFullscreen: boolean;\n  canPictureInPicture: boolean;\n  isPictureInPicture: boolean;\n  engineStatus: EngineStatus;\n  error: PlayerError | null;\n  capabilities: EngineCapabilities;\n}\n\n/**\n * En direct, et pas seulement d'après le moteur : une durée non finie le dit\n * aussi, et c'est la seule source dont on dispose en HLS natif, là où Shaka ne\n * tourne pas. Les deux comptent, sans quoi un iPhone d'avant iOS 17.1\n * afficherait une vidéo à la demande sans fin.\n */\nexport function selectIsLive(state: PlayerState): boolean {\n  return state.capabilities.isLive || !Number.isFinite(state.duration);\n}\n\nexport interface PlayerStateStore {\n  subscribe(listener: () => void): () => void;\n  getSnapshot(): PlayerState;\n  getServerSnapshot(): PlayerState;\n  /** Fusionne et ne prévient que si quelque chose a réellement changé. */\n  patch(next: Partial<PlayerState>): void;\n}\n\n/**\n * Optimiste sur les capacités qu'on ne peut pas connaître sans le DOM : on ne\n * grise pas un bouton le temps d'un rendu pour le réactiver juste après.\n */\nexport function createInitialPlayerState(overrides?: Partial<PlayerState>): PlayerState {\n  return {\n    paused: true,\n    ended: false,\n    isBuffering: false,\n    duration: 0,\n    volume: 1,\n    muted: false,\n    playbackRate: 1,\n    canControlVolume: true,\n    canFullscreen: false,\n    isFullscreen: false,\n    canPictureInPicture: false,\n    isPictureInPicture: false,\n    engineStatus: \"idle\",\n    error: null,\n    capabilities: NO_CAPABILITIES,\n    ...overrides,\n  };\n}\n\nexport function createPlayerStateStore(initial: PlayerState): PlayerStateStore {\n  const listeners = new Set<() => void>();\n  // Figé : `getServerSnapshot` doit renvoyer la même référence à chaque appel,\n  // et le rendu serveur ne doit jamais voir une valeur venue du navigateur.\n  const server: PlayerState = Object.freeze({ ...initial });\n  let snapshot = initial;\n\n  return {\n    subscribe(listener) {\n      listeners.add(listener);\n      return () => {\n        listeners.delete(listener);\n      };\n    },\n    getSnapshot() {\n      return snapshot;\n    },\n    getServerSnapshot() {\n      return server;\n    },\n    patch(next) {\n      let changed = false;\n      for (const key of Object.keys(next) as (keyof PlayerState)[]) {\n        if (!Object.is(snapshot[key], next[key])) {\n          changed = true;\n          break;\n        }\n      }\n      // Sans ce garde-fou, un `volumechange` qui réécrit la même valeur — il y\n      // en a à chaque frame sur certains navigateurs — réveillerait tous les\n      // abonnés pour rien.\n      if (!changed) return;\n      snapshot = { ...snapshot, ...next };\n      for (const listener of listeners) listener();\n    },\n  };\n}\n",
      "type": "registry:ui",
      "target": "@ui/video-player/player-state-store.ts"
    },
    {
      "path": "registry/videocn/use-player.ts",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  type RefCallback,\n  type RefObject,\n} from \"react\";\n\nimport { NO_CAPABILITIES } from \"./player-engine\";\nimport type { PlayerEngine, PlayerError, SourceType } from \"./player-engine\";\nimport {\n  createInitialPlayerState,\n  createPlayerStateStore,\n  type PlayerState,\n  type PlayerStateStore,\n} from \"./player-state-store\";\nimport { createPlayheadStore, type PlayheadStore } from \"./playhead-store\";\nimport { resolveEngine } from \"./resolve-engine\";\n\nexport type { PlayerState } from \"./player-state-store\";\n\nexport interface PlayerActions {\n  play(): Promise<void>;\n  pause(): void;\n  togglePlay(): void;\n  seek(time: number): void;\n  /** Avance (positif) ou recule (négatif) depuis la position réelle de l'élément. */\n  seekBy(seconds: number): void;\n  /** Ramène au bord du direct, et relance la lecture si elle était arrêtée. */\n  goToLive(): void;\n  setVolume(volume: number): void;\n  /**\n   * Monte ou baisse le volume d'un pas, en tenant compte du muet. La keymap et\n   * le curseur de volume passent tous deux par ici : une flèche fait la même\n   * chose des deux côtés.\n   */\n  stepVolume(delta: number): void;\n  setMuted(muted: boolean): void;\n  toggleMuted(): void;\n  setPlaybackRate(rate: number): void;\n  toggleFullscreen(): void;\n  togglePictureInPicture(): void;\n  selectQuality(id: string | null): void;\n}\n\nexport interface UsePlayerOptions {\n  src: string;\n  type?: SourceType;\n  /** 0 à 1. Défaut 0,5. */\n  defaultVolume?: number;\n  defaultMuted?: boolean;\n  /** Cible du plein écran. À défaut, le `<video>` lui-même. */\n  containerRef?: RefObject<HTMLElement | null>;\n}\n\nexport interface UsePlayerResult {\n  /**\n   * À poser sur le `<video>`. C'est le hook qui fabrique la ref, et non\n   * l'appelant qui lui en confie une : une callback ref adossée à un état\n   * permet aux effets de suivre l'élément s'il est un jour remplacé — rendu\n   * conditionnel, `key` différente. Avec un objet ref figé, ils resteraient\n   * accrochés au nœud mort et le lecteur deviendrait inerte sans rien dire.\n   */\n  ref: RefCallback<HTMLVideoElement>;\n  /** L'état, à lire par tranches. Référence stable pour la vie du composant. */\n  store: PlayerStateStore;\n  actions: PlayerActions;\n  playhead: PlayheadStore;\n}\n\n/**\n * Assez fort pour être audible sans faire sursauter, assez bas pour qu'on\n * monte le son plutôt que de couper.\n */\nconst DEFAULT_VOLUME = 0.5;\n\n/** Voir `goToLive` : la marge qui évite de se poser sur un segment absent. */\nconst LIVE_EDGE_MARGIN = 1;\n\n/**\n * Les événements qui font bouger l'état « lent ». `timeupdate` et `progress`\n * n'y sont volontairement pas : ils appartiennent au store de la tête de\n * lecture, et les faire passer par React annulerait tout l'intérêt du store.\n */\nconst MEDIA_EVENTS = [\n  \"loadstart\",\n  \"loadedmetadata\",\n  \"canplay\",\n  \"canplaythrough\",\n  \"waiting\",\n  \"stalled\",\n  \"durationchange\",\n  \"play\",\n  \"playing\",\n  \"pause\",\n  \"ended\",\n  \"seeking\",\n  \"seeked\",\n  \"emptied\",\n  \"volumechange\",\n  \"ratechange\",\n] as const;\n\n/**\n * Safari n'a jamais retiré ses préfixes, et l'iPhone n'a jamais eu l'API\n * standard du tout. On teste la présence des méthodes plutôt que la marque du\n * navigateur : le jour où WebKit s'aligne, ce code n'a pas à changer.\n */\ninterface WebkitFullscreenElement {\n  webkitRequestFullscreen?: () => Promise<void> | void;\n}\n\ninterface WebkitFullscreenDocument {\n  webkitFullscreenElement?: Element | null;\n  webkitExitFullscreen?: () => Promise<void> | void;\n}\n\n/**\n * Sur iPhone, seule la vidéo passe en plein écran, et c'est le système qui la\n * rend : notre interface disparaît le temps du plein écran. C'est un repli, pas\n * un choix — on ne l'utilise que s'il est la seule option.\n */\ninterface WebkitFullscreenVideo {\n  webkitEnterFullscreen?: () => void;\n  webkitExitFullscreen?: () => void;\n  webkitDisplayingFullscreen?: boolean;\n}\n\ntype FullscreenMode = \"element\" | \"webkit\" | \"video\" | \"none\";\n\nfunction webkitDocument(): Document & WebkitFullscreenDocument {\n  return document as Document & WebkitFullscreenDocument;\n}\n\nfunction webkitVideo(video: HTMLVideoElement): HTMLVideoElement & WebkitFullscreenVideo {\n  return video as HTMLVideoElement & WebkitFullscreenVideo;\n}\n\nfunction detectFullscreenMode(target: HTMLElement, video: HTMLVideoElement): FullscreenMode {\n  if (typeof target.requestFullscreen === \"function\") return \"element\";\n  const prefixed = target as HTMLElement & WebkitFullscreenElement;\n  if (typeof prefixed.webkitRequestFullscreen === \"function\") return \"webkit\";\n  if (typeof webkitVideo(video).webkitEnterFullscreen === \"function\") return \"video\";\n  return \"none\";\n}\n\nfunction isFullscreenActive(target: HTMLElement, video: HTMLVideoElement): boolean {\n  const doc = webkitDocument();\n  const active = doc.fullscreenElement ?? doc.webkitFullscreenElement ?? null;\n  // Un autre élément de la page peut être en plein écran sans que ça nous\n  // concerne ; on ne répond vrai que si c'est bien notre cible.\n  if (active) return active === target || active.contains(target);\n  // Plein écran natif iOS : le document n'en sait rien, seule la vidéo le sait.\n  return webkitVideo(video).webkitDisplayingFullscreen === true;\n}\n\n/**\n * Plein écran et Picture-in-Picture rejettent quand le geste utilisateur\n * manque ou quand l'utilisateur refuse. Ce n'est pas une erreur du lecteur : le\n * `PlayerState.error` est réservé à ce qui empêche la lecture.\n */\nfunction ignoreRejection(result: unknown): void {\n  if (result instanceof Promise) void result.catch(() => undefined);\n}\n\nfunction clamp01(value: number): number {\n  if (!Number.isFinite(value)) return 0;\n  return Math.min(Math.max(value, 0), 1);\n}\n\nfunction toPlayerError(error: MediaError | null): PlayerError {\n  // Les navigateurs remplissent rarement `message`, et jamais dans la langue de\n  // l'hôte : c'est le `code` qui porte l'information exploitable.\n  const message = error?.message || \"Playback failed.\";\n  if (error) {\n    if (error.code === error.MEDIA_ERR_ABORTED) return { code: \"aborted\", message };\n    if (error.code === error.MEDIA_ERR_NETWORK) return { code: \"network\", message };\n    if (error.code === error.MEDIA_ERR_DECODE) return { code: \"decode\", message };\n  }\n  // `MEDIA_ERR_SRC_NOT_SUPPORTED`, et le cas où l'élément a émis `error` sans\n  // exposer de `MediaError` : dans les deux cas le navigateur ne sait pas lire\n  // cette source.\n  return { code: \"unsupported\", message };\n}\n\nfunction engineErrorMessage(cause: unknown): string {\n  return cause instanceof Error ? cause.message : \"The video engine could not load the source.\";\n}\n\nexport function usePlayer(options: UsePlayerOptions): UsePlayerResult {\n  const { src, type, defaultVolume = DEFAULT_VOLUME, defaultMuted, containerRef } = options;\n\n  // L'élément vit à deux endroits, et c'est voulu. L'état le fait suivre aux\n  // effets, qui doivent se rejouer s'il change. La ref le donne aux actions\n  // sans leur faire changer d'identité, pour que les contrôles qui les\n  // reçoivent en props ne se re-rendent pas pour autant.\n  const videoRef = useRef<HTMLVideoElement | null>(null);\n  const [video, setVideo] = useState<HTMLVideoElement | null>(null);\n\n  const attachVideo = useCallback((node: HTMLVideoElement | null) => {\n    videoRef.current = node;\n    setVideo(node);\n  }, []);\n\n  // Les valeurs par défaut n'entrent que dans l'état initial : le lecteur est\n  // non contrôlé, et le store n'est jamais recréé si elles changent ensuite.\n  const [store] = useState(() =>\n    createPlayerStateStore(\n      createInitialPlayerState({\n        volume: clamp01(defaultVolume),\n        muted: defaultMuted ?? false,\n      }),\n    ),\n  );\n\n  // Un seul store pour la vie du composant : l'initialiseur paresseux de\n  // `useState` est le seul moyen que React garantisse de n'appeler qu'une fois.\n  const [playhead] = useState(createPlayheadStore);\n\n  const engineRef = useRef<PlayerEngine | null>(null);\n  /**\n   * Le démontage du moteur précédent, tant qu'il n'a pas rendu l'élément. Une\n   * ref et non un état : personne ne se rend pour ça, et c'est le prochain\n   * effet qui doit la lire.\n   */\n  const teardownRef = useRef<Promise<void> | null>(null);\n  const fullscreenModeRef = useRef<FullscreenMode>(\"none\");\n  const defaultsAppliedRef = useRef(false);\n\n  useEffect(() => {\n    if (!video) return;\n    return playhead.attach(video);\n  }, [playhead, video]);\n\n  useEffect(() => {\n    if (!video) return;\n\n    const sync = () => {\n      const next: Partial<PlayerState> = {\n        paused: video.paused,\n        ended: video.ended,\n        // Déduit de l'élément plutôt que mémorisé sur `waiting` / `playing` :\n        // un état qui se recalcule ne peut pas rester coincé sur vrai après un\n        // événement manqué.\n        isBuffering: !video.paused && video.readyState < video.HAVE_FUTURE_DATA,\n        // `NaN` tant que les métadonnées manquent. `Infinity` en live est\n        // conservé tel quel : c'est une information, pas une valeur absente.\n        duration: Number.isNaN(video.duration) ? 0 : video.duration,\n        volume: video.volume,\n        muted: video.muted,\n        playbackRate: video.playbackRate,\n      };\n      // `engineStatus` n'est pas ici : c'est l'effet du moteur qui le tient, et\n      // il en sait plus que l'élément — notamment pendant le chargement de Shaka.\n      store.patch(next);\n    };\n\n    const handleError = () => {\n      // Un `error` sans `MediaError` est un résidu : l'élément est déjà reparti\n      // sur une autre source et a effacé la précédente. Le rapporter\n      // inventerait une panne.\n      if (!video.error) return;\n      // Libérer une source — au démontage, ou avant d'en charger une autre —\n      // laisse l'élément sans candidat, ce que le navigateur signale comme un\n      // format illisible. Il n'y a rien à lire, donc rien qui ait échoué.\n      if (!video.currentSrc && !video.getAttribute(\"src\")) return;\n      store.patch({ error: toPlayerError(video.error), engineStatus: \"error\" });\n    };\n\n    sync();\n    for (const event of MEDIA_EVENTS) video.addEventListener(event, sync);\n    video.addEventListener(\"error\", handleError);\n\n    return () => {\n      for (const event of MEDIA_EVENTS) video.removeEventListener(event, sync);\n      video.removeEventListener(\"error\", handleError);\n    };\n  }, [store, video]);\n\n  useEffect(() => {\n    if (!video) return;\n    // Le nœud repasse par la ref pour être **écrit**. C'est le même élément :\n    // l'état déclenche l'effet, la ref l'autorise à agir. Muter directement\n    // `video`, qui vient de l'état, reviendrait à modifier une valeur issue du\n    // rendu — ce que React interdit, et ce que le compilateur refuse.\n    const element = videoRef.current;\n    if (!element) return;\n\n    if (!defaultsAppliedRef.current) {\n      defaultsAppliedRef.current = true;\n      // Le lecteur est **non contrôlé** : ces valeurs sont un point de départ,\n      // pas une source de vérité. Rien ne les réapplique ensuite.\n      element.volume = clamp01(defaultVolume);\n      if (defaultMuted !== undefined) element.muted = defaultMuted;\n    }\n\n    // Sur iPhone, Safari ignore les écritures sur `video.volume` : la propriété\n    // vaut toujours 1 et le curseur de volume n'aurait aucun effet. Rien ne\n    // l'annonce, il faut essayer. La valeur trouvée est restaurée aussitôt.\n    const found = element.volume;\n    const probe = found === 1 ? 0.5 : 1;\n    element.volume = probe;\n    const canControlVolume = element.volume === probe;\n    element.volume = found;\n\n    // Publié seulement maintenant, et il n'y a pas d'autre moyen : la capacité\n    // ne se connaît qu'en essayant, et essayer pendant le rendu casserait le\n    // rendu serveur.\n    store.patch({ canControlVolume, volume: element.volume, muted: element.muted });\n  }, [defaultMuted, defaultVolume, store, video]);\n\n  useEffect(() => {\n    if (!video) return;\n\n    let active = true;\n    const engine = resolveEngine(src, type);\n    engineRef.current = engine;\n\n    const syncCapabilities = () => {\n      if (active) store.patch({ capabilities: engine.getCapabilities() });\n    };\n    // `ready` ne vient pas de la promesse du moteur : celle-ci dit seulement\n    // que la source est prise en charge. C'est l'élément qui dit quand la\n    // lecture devient possible.\n    const markReady = () => {\n      if (active) store.patch({ engineStatus: \"ready\" });\n    };\n\n    // On ne sait qu'un chargement commence qu'une fois le moteur résolu, ce qui\n    // exige l'élément, qui n'existe qu'après le montage.\n    store.patch({ engineStatus: \"loading\", error: null, capabilities: engine.getCapabilities() });\n    const unsubscribe = engine.subscribe(syncCapabilities);\n    video.addEventListener(\"loadedmetadata\", markReady);\n    video.addEventListener(\"canplay\", markReady);\n\n    const start = async () => {\n      // Le moteur précédent peut être encore en train de rendre l'élément : un\n      // changement de `src` démonte l'un et monte l'autre dans la même frame,\n      // alors que Shaka détache de façon asynchrone. Attacher sans attendre\n      // laisse la balise à deux maîtres, et elle reste muette — mesuré :\n      // « chargement » perpétuel, `readyState 0`, aucune erreur pour le dire.\n      const teardown = teardownRef.current;\n      if (teardown) {\n        await teardown;\n        if (teardownRef.current === teardown) teardownRef.current = null;\n        if (!active) return;\n      }\n\n      engine.attach(video);\n      try {\n        await engine.load(src);\n        if (!active) return;\n        syncCapabilities();\n        // Un élément qui portait déjà cette source a émis `loadedmetadata`\n        // avant qu'on écoute : l'événement ne reviendra pas.\n        if (video.readyState >= video.HAVE_METADATA) markReady();\n      } catch (cause: unknown) {\n        if (!active) return;\n        store.patch({\n          engineStatus: \"error\",\n          error: { code: \"engine\", message: engineErrorMessage(cause) },\n        });\n      }\n    };\n    void start();\n\n    return () => {\n      active = false;\n      unsubscribe();\n      video.removeEventListener(\"loadedmetadata\", markReady);\n      video.removeEventListener(\"canplay\", markReady);\n      // La promesse, quand il y en a une, dit quand l'élément est réellement\n      // rendu. C'est elle qu'attendra le moteur suivant.\n      teardownRef.current = Promise.resolve(engine.destroy());\n      if (engineRef.current === engine) engineRef.current = null;\n      // La source vient d'être libérée : repartir de zéro plutôt que de laisser\n      // l'ancien statut décrire la suivante.\n      store.patch({ engineStatus: \"idle\", error: null, capabilities: NO_CAPABILITIES });\n    };\n  }, [src, store, type, video]);\n\n  useEffect(() => {\n    if (!video) return;\n    const target = containerRef?.current ?? video;\n\n    const mode = detectFullscreenMode(target, video);\n    fullscreenModeRef.current = mode;\n\n    const sync = () => {\n      store.patch({\n        canFullscreen: mode !== \"none\",\n        isFullscreen: isFullscreenActive(target, video),\n      });\n    };\n    sync();\n\n    document.addEventListener(\"fullscreenchange\", sync);\n    document.addEventListener(\"webkitfullscreenchange\", sync);\n    // Le plein écran natif iOS n'émet rien sur le document : la vidéo est la\n    // seule à en parler.\n    video.addEventListener(\"webkitbeginfullscreen\", sync);\n    video.addEventListener(\"webkitendfullscreen\", sync);\n\n    return () => {\n      document.removeEventListener(\"fullscreenchange\", sync);\n      document.removeEventListener(\"webkitfullscreenchange\", sync);\n      video.removeEventListener(\"webkitbeginfullscreen\", sync);\n      video.removeEventListener(\"webkitendfullscreen\", sync);\n    };\n  }, [containerRef, store, video]);\n\n  useEffect(() => {\n    if (!video) return;\n\n    const sync = () => {\n      store.patch({\n        canPictureInPicture:\n          Boolean(document.pictureInPictureEnabled) && !video.disablePictureInPicture,\n        isPictureInPicture: document.pictureInPictureElement === video,\n      });\n    };\n    sync();\n\n    video.addEventListener(\"enterpictureinpicture\", sync);\n    video.addEventListener(\"leavepictureinpicture\", sync);\n    // L'attribut `disablePictureInPicture` peut arriver avec une nouvelle\n    // source : on repasse dessus à chaque chargement.\n    video.addEventListener(\"loadedmetadata\", sync);\n\n    return () => {\n      video.removeEventListener(\"enterpictureinpicture\", sync);\n      video.removeEventListener(\"leavepictureinpicture\", sync);\n      video.removeEventListener(\"loadedmetadata\", sync);\n    };\n  }, [store, video]);\n\n  const play = useCallback(() => {\n    const video = videoRef.current;\n    return video ? video.play() : Promise.resolve();\n  }, []);\n\n  const pause = useCallback(() => {\n    videoRef.current?.pause();\n  }, []);\n\n  const togglePlay = useCallback(() => {\n    const video = videoRef.current;\n    if (!video) return;\n    // On lit l'élément plutôt que l'état : entre deux rendus, la vidéo peut\n    // s'être arrêtée d'elle-même, et c'est elle qui a raison.\n    if (video.paused || video.ended) ignoreRejection(video.play());\n    else video.pause();\n  }, []);\n\n  const seek = useCallback(\n    (time: number) => {\n      const video = videoRef.current;\n      if (!video || !Number.isFinite(time)) return;\n      // En live, `duration` vaut `Infinity` : il n'y a pas de borne haute à\n      // appliquer, le navigateur ramènera lui-même dans la fenêtre DVR.\n      const upperBound = Number.isFinite(video.duration) ? video.duration : time;\n      video.currentTime = Math.min(Math.max(time, 0), upperBound);\n    },\n    [],\n  );\n\n  const seekBy = useCallback(\n    (seconds: number) => {\n      const video = videoRef.current;\n      if (!video) return;\n      // L'élément et non la tête de lecture du store : `currentTime` est à\n      // jour dès l'écriture, alors que le store attend `seeking`. Deux `←`\n      // rapprochés partiraient sinon du même point, et l'un serait perdu.\n      seek(video.currentTime + seconds);\n    },\n    [seek],\n  );\n\n  /**\n   * Le bord du direct se vise avec une marge : la dernière seconde diffusée\n   * n'est pas encore téléchargée, et s'y poser fait caler la lecture le temps\n   * que le segment arrive. Une seconde, c'est assez pour tomber dans ce qui est\n   * déjà là sans qu'on se sente en retard.\n   */\n  const goToLive = useCallback(() => {\n    const video = videoRef.current;\n    if (!video) return;\n    const { seekable } = video;\n    if (seekable.length === 0) return;\n    const edge = seekable.end(seekable.length - 1);\n    const start = seekable.start(0);\n    video.currentTime = Math.max(edge - LIVE_EDGE_MARGIN, start);\n    // Revenir au direct sur une vidéo en pause n'aurait aucun sens : c'est\n    // repartir qu'on demande.\n    if (video.paused) ignoreRejection(video.play());\n  }, []);\n\n  const setVolume = useCallback(\n    (volume: number) => {\n      const video = videoRef.current;\n      if (!video) return;\n      // On écrit sur l'élément et on s'arrête là : l'état suivra\n      // `volumechange`. L'inverse ferait mentir l'interface sur un iPhone, où\n      // l'écriture n'a aucun effet.\n      video.volume = clamp01(volume);\n    },\n    [],\n  );\n\n  /**\n   * Depuis le muet, un pas vers le haut rend le son au volume d'avant la\n   * coupure, sans l'augmenter : c'est ce qu'on attend en appuyant, et repartir\n   * des 0 % affichés obligerait à remonter pas à pas. Si ce volume était nul,\n   * le pas lui-même sert de volume. Vers le bas, le muet reste muet : baisser un\n   * son qu'on n'entend pas ne veut rien dire, et écraser le volume mémorisé\n   * ferait perdre ce que `m` rétablira.\n   */\n  const stepVolume = useCallback((delta: number) => {\n    const video = videoRef.current;\n    if (!video || !Number.isFinite(delta)) return;\n    if (video.muted) {\n      if (delta <= 0) return;\n      // `max` et non le seul volume mémorisé : `End` sur le curseur arrive ici\n      // avec un pas de 1, et doit mener au maximum.\n      video.volume = Math.max(video.volume, clamp01(delta));\n      video.muted = false;\n      return;\n    }\n    // Arrondi au millième : 0,05 ajouté pas à pas ne tombe pas juste en\n    // virgule flottante, et `aria-valuenow` porterait 0.6000000000000001.\n    video.volume = clamp01(Math.round((video.volume + delta) * 1000) / 1000);\n  }, []);\n\n  const setMuted = useCallback(\n    (muted: boolean) => {\n      const video = videoRef.current;\n      if (video) video.muted = muted;\n    },\n    [],\n  );\n\n  const toggleMuted = useCallback(() => {\n    const video = videoRef.current;\n    if (video) video.muted = !video.muted;\n  }, []);\n\n  const setPlaybackRate = useCallback(\n    (rate: number) => {\n      const video = videoRef.current;\n      if (!video || !Number.isFinite(rate) || rate <= 0) return;\n      video.playbackRate = rate;\n    },\n    [],\n  );\n\n  const toggleFullscreen = useCallback(() => {\n    const video = videoRef.current;\n    if (!video) return;\n    const target = containerRef?.current ?? video;\n    const doc = webkitDocument();\n    const prefixedVideo = webkitVideo(video);\n\n    if (isFullscreenActive(target, video)) {\n      if (typeof doc.exitFullscreen === \"function\") ignoreRejection(doc.exitFullscreen());\n      else if (typeof doc.webkitExitFullscreen === \"function\") doc.webkitExitFullscreen();\n      else prefixedVideo.webkitExitFullscreen?.();\n      return;\n    }\n\n    switch (fullscreenModeRef.current) {\n      case \"element\":\n        ignoreRejection(target.requestFullscreen());\n        break;\n      case \"webkit\":\n        (target as HTMLElement & WebkitFullscreenElement).webkitRequestFullscreen?.();\n        break;\n      case \"video\":\n        prefixedVideo.webkitEnterFullscreen?.();\n        break;\n      default:\n        break;\n    }\n  }, [containerRef]);\n\n  const togglePictureInPicture = useCallback(() => {\n    const video = videoRef.current;\n    if (!video) return;\n    if (document.pictureInPictureElement === video) {\n      ignoreRejection(document.exitPictureInPicture());\n      return;\n    }\n    if (typeof video.requestPictureInPicture === \"function\") {\n      ignoreRejection(video.requestPictureInPicture());\n    }\n  }, []);\n\n  const selectQuality = useCallback((id: string | null) => {\n    engineRef.current?.selectQuality(id);\n  }, []);\n\n  /**\n   * Les actions sont stables pour la vie du composant : les contrôles les\n   * reçoivent en props et ne doivent pas se re-rendre parce que la tête de\n   * lecture a avancé.\n   */\n  const actions = useMemo<PlayerActions>(\n    () => ({\n      play,\n      pause,\n      togglePlay,\n      seek,\n      seekBy,\n      goToLive,\n      setVolume,\n      stepVolume,\n      setMuted,\n      toggleMuted,\n      setPlaybackRate,\n      toggleFullscreen,\n      togglePictureInPicture,\n      selectQuality,\n    }),\n    [\n      play,\n      pause,\n      togglePlay,\n      seek,\n      seekBy,\n      goToLive,\n      setVolume,\n      stepVolume,\n      setMuted,\n      toggleMuted,\n      setPlaybackRate,\n      toggleFullscreen,\n      togglePictureInPicture,\n      selectQuality,\n    ],\n  );\n\n  // Que des références stables : l'objet ne change plus d'identité après le\n  // montage, et le contexte qui le porte ne re-rend donc jamais personne.\n  return useMemo(\n    () => ({ ref: attachVideo, store, actions, playhead }),\n    [attachVideo, store, actions, playhead],\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/video-player/use-player.ts"
    },
    {
      "path": "registry/videocn/player-context.tsx",
      "content": "\"use client\";\n\nimport { createContext, useContext, useSyncExternalStore, type ReactNode } from \"react\";\n\nimport type { PlayheadSnapshot, PlayheadStore } from \"./playhead-store\";\nimport type { PlayerState, PlayerStateStore } from \"./player-state-store\";\nimport type { PlayerActions, UsePlayerResult } from \"./use-player\";\n\n/**\n * L'état est détenu une fois, par le composant racine, et distribué ici. Les\n * contrôles lisent et rendent ; aucun d'eux ne détient d'état, sans quoi deux\n * contrôles pourraient afficher deux vérités différentes.\n *\n * **Le contexte ne porte que des références stables** — deux stores et un objet\n * d'actions, qui ne changent jamais de la vie du lecteur. Rien ne transite par\n * sa valeur, donc le changer ne re-rend personne. Toute la réactivité passe par\n * des abonnements, et chaque contrôle ne se réveille que pour la tranche qu'il\n * lit. C'est ce qui fait qu'un glissement de volume ne re-rend plus le bouton\n * plein écran.\n */\n\ninterface PlayerHandle {\n  store: PlayerStateStore;\n  actions: PlayerActions;\n  playhead: PlayheadStore;\n}\n\nconst PlayerContext = createContext<PlayerHandle | null>(null);\n\nexport interface PlayerProviderProps {\n  value: UsePlayerResult;\n  children: ReactNode;\n}\n\nexport function PlayerProvider({ value, children }: PlayerProviderProps) {\n  return <PlayerContext.Provider value={value}>{children}</PlayerContext.Provider>;\n}\n\nfunction usePlayerHandle(): PlayerHandle {\n  const handle = useContext(PlayerContext);\n  if (!handle) {\n    throw new Error(\"The player controls must be rendered inside <VideoCn>.\");\n  }\n  return handle;\n}\n\n/**\n * Lit **une tranche** de l'état, et ne re-rend que si elle change.\n *\n * Le sélecteur doit renvoyer une valeur comparable par `Object.is` : un nombre,\n * un booléen, une chaîne, ou une référence stable comme `capabilities`.\n * Fabriquer un objet dans le sélecteur — `(s) => ({ a: s.a })` — rendrait la\n * comparaison toujours fausse et re-rendrait à chaque notification.\n */\nexport function usePlayerValue<T>(select: (state: PlayerState) => T): T {\n  return usePlayerStoreValue(usePlayerHandle().store, select);\n}\n\n/**\n * La même chose, sur un store qu'on tient déjà en main. Le composant racine en\n * a besoin : il lit deux champs *avant* de fournir le contexte, donc il ne peut\n * pas passer par `usePlayerValue`.\n */\nexport function usePlayerStoreValue<T>(\n  store: PlayerStateStore,\n  select: (state: PlayerState) => T,\n): T {\n  return useSyncExternalStore(\n    store.subscribe,\n    () => select(store.getSnapshot()),\n    () => select(store.getServerSnapshot()),\n  );\n}\n\n/** Les actions. Leurs références sont stables : les passer en prop ne re-rend rien. */\nexport function usePlayerActions(): PlayerActions {\n  return usePlayerHandle().actions;\n}\n\n/**\n * Lit **une tranche** de la tête de lecture, et ne re-rend que si elle change.\n *\n * La tête bouge à chaque frame : c'est le sélecteur qui décide du rythme. Le\n * scrubber et l'horodatage lisent la seconde entière — un rendu par seconde de\n * média, pas soixante. Même règle que `usePlayerValue` : une valeur comparable\n * par `Object.is`, jamais un objet fabriqué dans le sélecteur.\n */\nexport function usePlayheadValue<T>(select: (snapshot: PlayheadSnapshot) => T): T {\n  const { playhead } = usePlayerHandle();\n  return useSyncExternalStore(\n    playhead.subscribe,\n    () => select(playhead.getSnapshot()),\n    () => select(playhead.getServerSnapshot()),\n  );\n}\n\n/**\n * Les stores tels quels, **jamais pour le rendu** : pour s'abonner dans un effet\n * ou lire une valeur dans un gestionnaire. Le scrubber s'en sert pour dessiner\n * sa position hors de React, soixante fois par seconde, sans le moindre rendu.\n */\nexport function usePlayheadStore(): PlayheadStore {\n  return usePlayerHandle().playhead;\n}\n\nexport function usePlayerStore(): PlayerStateStore {\n  return usePlayerHandle().store;\n}\n",
      "type": "registry:ui",
      "target": "@ui/video-player/player-context.tsx"
    },
    {
      "path": "registry/videocn/format-time.ts",
      "content": "\"use client\";\n\n/**\n * Deux écritures d'un même temps : celle qu'on lit à l'écran, `9:56`, et celle\n * qu'un lecteur d'écran prononce, « 9 minutes 56 seconds ». La seconde n'est\n * pas une politesse : lu tel quel, `9:56` devient « neuf deux-points\n * cinquante-six », ou une heure de la journée.\n */\n\nconst SECONDS_PER_HOUR = 3600;\n\nfunction split(seconds: number): { hours: number; minutes: number; secs: number } {\n  const total = Math.floor(seconds);\n  return {\n    hours: Math.floor(total / SECONDS_PER_HOUR),\n    minutes: Math.floor((total % SECONDS_PER_HOUR) / 60),\n    secs: total % 60,\n  };\n}\n\n/** `NaN`, négatif ou infini : rien de sensé à afficher, on repart de zéro. */\nfunction sanitize(seconds: number): number {\n  return Number.isFinite(seconds) && seconds > 0 ? seconds : 0;\n}\n\n/**\n * `m:ss`, ou `h:mm:ss` dès que `reference` atteint une heure.\n *\n * La forme est dictée par la **durée** et non par la valeur, pour que les deux\n * côtés de `0:42 / 1:02:13` aient la même écriture — `0:00:42` — et que le\n * libellé garde sa largeur quand la lecture franchit l'heure. Sans référence\n * finie (le direct), la valeur décide seule.\n */\nexport function formatTime(seconds: number, reference: number = seconds): string {\n  const value = sanitize(seconds);\n  const scale = Number.isFinite(reference) ? Math.max(sanitize(reference), value) : value;\n  const { hours, minutes, secs } = split(value);\n  const ss = String(secs).padStart(2, \"0\");\n\n  if (scale >= SECONDS_PER_HOUR) {\n    return `${hours}:${String(minutes).padStart(2, \"0\")}:${ss}`;\n  }\n  // Sans heure, les minutes ne sont pas complétées : `0:42`, `9:56`, `59:59`.\n  return `${hours * 60 + minutes}:${ss}`;\n}\n\nfunction unit(count: number, singular: string): string {\n  return `${count} ${count === 1 ? singular : `${singular}s`}`;\n}\n\n/**\n * La forme parlée, en anglais comme les autres libellés du lecteur. Les unités\n * nulles sont omises — « 1 hour 5 seconds » plutôt que « 1 hour 0 minutes\n * 5 seconds » — sauf si tout est nul.\n */\nexport function formatSpokenTime(seconds: number): string {\n  const { hours, minutes, secs } = split(sanitize(seconds));\n  const parts: string[] = [];\n  if (hours > 0) parts.push(unit(hours, \"hour\"));\n  if (minutes > 0) parts.push(unit(minutes, \"minute\"));\n  if (secs > 0 || parts.length === 0) parts.push(unit(secs, \"second\"));\n  return parts.join(\" \");\n}\n",
      "type": "registry:ui",
      "target": "@ui/video-player/format-time.ts"
    },
    {
      "path": "registry/videocn/chapters.ts",
      "content": "\"use client\";\n\n/**\n * Les chapitres, de ce que l'intégrateur écrit à ce que le lecteur dessine.\n *\n * **Deux formes, et c'est délibéré.** Dehors, un début et un titre : c'est tout\n * ce qu'on peut demander à quelqu'un qui tape sa liste à la main, et la fin\n * d'un chapitre est de toute façon le début du suivant. Dedans, une plage\n * complète et ses deux fractions, calculées une fois pour toutes.\n *\n * Ce passage d'une forme à l'autre n'est pas une coquetterie : il isole le\n * rendu de la source. Le jour où les chapitres arriveront d'un fichier WebVTT\n * — qui, lui, porte des fins explicites —, il suffira de produire le même\n * `ResolvedChapter[]`. Ni la piste ni le menu n'en sauront rien.\n */\n\n/** Ce que l'intégrateur écrit. */\nexport interface Chapter {\n  /** Le début, en secondes depuis l'origine de la vidéo. */\n  time: number;\n  label: string;\n}\n\n/** Ce que le lecteur consomme. Fabriqué par `resolveChapters`, jamais à la main. */\nexport interface ResolvedChapter {\n  /** En secondes. */\n  start: number;\n  /** En secondes. Le début du chapitre suivant, ou la durée pour le dernier. */\n  end: number;\n  label: string;\n  /** `start` rapporté à la durée, 0→1 : ce que vaut `--chapter-start`. */\n  fraction: number;\n  /** La largeur rapportée à la durée, 0→1 : ce que vaut `--chapter-span`. */\n  span: number;\n}\n\n/**\n * Une référence stable pour « pas de chapitres ».\n *\n * Elle compte : la liste descend par un contexte et sert de dépendance à des\n * mémos et à un effet. Un tableau vide fabriqué à chaque appel les\n * déclencherait tous, à chaque rendu, pour rien.\n */\nexport const NO_CHAPTERS: readonly ResolvedChapter[] = Object.freeze([]);\n\n/**\n * Le plancher d'une largeur de chapitre.\n *\n * `--chapter-span` est un diviseur dans la feuille de style. À zéro, la\n * déclaration devient invalide et le segment disparaît — pas d'erreur, juste\n * un trou. Les débuts étant uniques et triés, une largeur nulle ne devrait\n * jamais survenir ; ce plancher est là pour que l'arithmétique flottante ne\n * puisse pas en fabriquer une.\n */\nconst MIN_SPAN = 1e-6;\n\n/**\n * La liste normalisée, triée, bornée à la durée.\n *\n * L'intégrateur n'a rien à garantir : ni l'ordre, ni l'absence de doublons, ni\n * la validité des nombres. C'est ici qu'on absorbe tout ça, une fois, plutôt\n * que de s'en défendre dans chaque couche de rendu.\n *\n * Une durée inconnue ou infinie rend une liste vide, et le direct tombe dans ce\n * cas tout seul : sans durée, un chapitre n'a pas de fin, et une fenêtre qui\n * glisse ne se découpe pas. Aucune condition dédiée au direct n'est donc\n * nécessaire — ni ici, ni chez ceux qui lisent le résultat.\n */\nexport function resolveChapters(\n  chapters: readonly Chapter[] | undefined,\n  duration: number,\n): readonly ResolvedChapter[] {\n  if (!chapters || chapters.length === 0) return NO_CHAPTERS;\n  if (!Number.isFinite(duration) || duration <= 0) return NO_CHAPTERS;\n\n  // Une `Map` plutôt qu'un tri suivi d'un dédoublonnage : deux chapitres au\n  // même instant ne peuvent pas coexister — ils donneraient un segment de\n  // largeur nulle —, et c'est le premier écrit qui gagne, comme partout\n  // ailleurs quand une clé est répétée.\n  const starts = new Map<number, string>();\n  for (const chapter of chapters) {\n    const time = chapter?.time;\n    const label = typeof chapter?.label === \"string\" ? chapter.label.trim() : \"\";\n    // Un chapitre qui commence à la durée, ou après, ne serait jamais atteint.\n    if (!Number.isFinite(time) || time < 0 || time >= duration) continue;\n    if (label.length === 0) continue;\n    if (!starts.has(time)) starts.set(time, label);\n  }\n  if (starts.size === 0) return NO_CHAPTERS;\n\n  const sorted = Array.from(starts.entries()).sort((a, b) => a[0] - b[0]);\n\n  // Le premier chapitre commence à zéro, quoi qu'on nous ait donné. Un premier\n  // chapitre à 0:30 laisserait la barre nue sur son premier vingtième : un\n  // trou que personne ne saurait interpréter, et qui ferait croire à un bug\n  // plutôt qu'à un choix.\n  sorted[0][0] = 0;\n\n  const resolved = sorted.map(([start, label], index) => {\n    const end = index + 1 < sorted.length ? sorted[index + 1][0] : duration;\n    return Object.freeze({\n      start,\n      end,\n      label,\n      fraction: start / duration,\n      span: Math.max((end - start) / duration, MIN_SPAN),\n    });\n  });\n\n  return Object.freeze(resolved);\n}\n\n/**\n * L'index du chapitre qui contient cet instant, ou `-1`.\n *\n * Parcours à rebours : le premier chapitre commençant à zéro, le premier\n * `start` franchi en descendant est forcément le bon. Une boucle et non une\n * dichotomie — une liste de chapitres se compte en dizaines, et le coût d'un\n * appel est ici sans commune mesure avec celui du rendu qu'il évite.\n */\nexport function findChapterIndex(chapters: readonly ResolvedChapter[], time: number): number {\n  if (chapters.length === 0 || !Number.isFinite(time)) return -1;\n  for (let index = chapters.length - 1; index >= 0; index -= 1) {\n    if (time >= chapters[index].start) return index;\n  }\n  return -1;\n}\n",
      "type": "registry:ui",
      "target": "@ui/video-player/chapters.ts"
    },
    {
      "path": "registry/videocn/controls-options.ts",
      "content": "\"use client\";\n\n/**\n * Ce que le lecteur expose de réglable, et rien d'autre : `<VideoCn>` s'installe\n * et fonctionne. Masquer un contrôle, changer une liste de valeurs, tout passe\n * par ici — jamais par une modification du code livré.\n *\n * La forme tient sur la durée grâce à une seule règle : **chaque contrôle vaut\n * `boolean | objet d'options`**. Absent ou `true`, il est là avec ses défauts ;\n * `false`, il disparaît ; un objet, il est là et réglé.\n *\n * La frontière avec les props de `<VideoCn>` : ici on règle **ce qui s'affiche**,\n * là-bas on fournit **ce qu'il y a à afficher**. Les chapitres tombent des deux\n * côtés — une prop racine `chapters` porte la liste, deux clés d'ici décident du\n * menu et du découpage de la barre.\n */\n\nexport interface ControlsOptions {\n  /**\n   * `auto` — la barre apparaît à l'activité et s'efface pendant la lecture.\n   * `always` — jamais masquée. `never` — pas de barre du tout.\n   */\n  visibility?: \"auto\" | \"always\" | \"never\";\n  /** Inactivité avant masquage, en millisecondes. */\n  autoHideDelay?: number;\n  play?: boolean;\n  /**\n   * La barre de progression. `{ chapters: false }` la garde d'un seul tenant\n   * sans rien retirer au menu : découper la barre est un choix d'apparence, pas\n   * de contenu.\n   */\n  scrubber?: boolean | { chapters?: boolean };\n  /** L'horodatage `0:42 / 9:56`. */\n  time?: boolean;\n  volume?: boolean;\n  fullscreen?: boolean;\n  pictureInPicture?: boolean;\n  /**\n   * Le menu des chapitres. Contrairement au sélecteur de qualité, il\n   * **disparaît** quand la vidéo n'a pas de chapitres au lieu de rester grisé :\n   * la qualité existe pour toute vidéo, les chapitres sont une donnée que la\n   * plupart n'auront jamais, et un bouton mort sur chaque lecteur serait du\n   * bruit.\n   */\n  chapters?: boolean;\n  playbackRate?: boolean | { rates?: readonly number[] };\n  /** Le sélecteur de qualité. Grisé, et non masqué, quand le moteur n'expose rien. */\n  quality?: boolean;\n  /** La pastille « Direct », qui ne s'affiche que sur un flux en direct. */\n  live?: boolean;\n  /**\n   * Les raccourcis clavier — `Espace`, `k`, les flèches, `m`, `f`, `0`–`9` —,\n   * actifs quand le focus est dans le lecteur. `false` pour un hôte qui a déjà\n   * les siens.\n   */\n  keyboard?: boolean;\n}\n\n/**\n * Tous les contrôles sont des objets, y compris ceux qui n'ont aujourd'hui rien\n * à régler : le jour où l'un d'eux gagne une option, ce que son lecteur attend\n * ne change pas.\n */\nexport interface ResolvedControlsOptions {\n  visibility: \"auto\" | \"always\" | \"never\";\n  autoHideDelay: number;\n  play: { enabled: boolean };\n  scrubber: { enabled: boolean; chapters: boolean };\n  time: { enabled: boolean };\n  volume: { enabled: boolean };\n  fullscreen: { enabled: boolean };\n  pictureInPicture: { enabled: boolean };\n  chapters: { enabled: boolean };\n  playbackRate: { enabled: boolean; rates: readonly number[] };\n  quality: { enabled: boolean };\n  live: { enabled: boolean };\n  keyboard: { enabled: boolean };\n}\n\n/** Les vitesses de YouTube : assez fines pour être utiles, assez peu pour tenir dans un menu. */\nexport const DEFAULT_PLAYBACK_RATES: readonly number[] = Object.freeze([\n  0.5, 0.75, 1, 1.25, 1.5, 1.75, 2,\n]);\n\n/** Trois secondes : le temps de trouver un bouton sans que la barre s'incruste. */\nexport const DEFAULT_AUTO_HIDE_DELAY = 3000;\n\n/**\n * Le pas d'une flèche, en secondes sur le scrubber et en fraction sur le\n * volume. Déclarés ici parce que deux couches s'en servent — le curseur\n * focalisé et la keymap du lecteur — et qu'elles doivent tomber d'accord :\n * `→` ne peut pas avancer de 5 s sur le curseur et de 10 s ailleurs.\n */\nexport const DEFAULT_SEEK_STEP = 5;\nexport const DEFAULT_VOLUME_STEP = 0.05;\n\n/**\n * Ce qu'on tolère **au-delà du retard naturel du flux** avant de se dire en\n * retard sur le direct.\n *\n * Ce n'est pas un retard absolu, et c'est tout l'enjeu : le bord n'avance pas\n * régulièrement — la fin de `seekable` saute d'un segment à chaque\n * rafraîchissement de playlist —, et le retard de croisière dépend du flux, de\n * trois secondes en basse latence à une trentaine sur un HLS classique. Un\n * seuil absolu ferait donc clignoter la pastille sur les flux dont la latence\n * tombe juste dessus : mesuré sur un direct de démonstration, l'écart oscillait\n * entre 8,6 s et 10,7 s et un seuil à 10 s basculait onze fois en dix secondes.\n * Le retard naturel est donc **mesuré** par la tête de lecture, et c'est de lui\n * qu'on s'écarte — dix secondes, soit un segment de large, jamais moins que le\n * saut qui fait osciller l'écart.\n *\n * Ici pour la même raison que les pas ci-dessus : trois couches s'en servent —\n * la pastille, l'horodatage et le scrubber — et elles doivent tomber d'accord.\n */\nexport const LIVE_EDGE_TOLERANCE = 10;\n\nfunction toggle(value: boolean | undefined): { enabled: boolean } {\n  // Seul `false` masque : une clé absente doit donner un lecteur complet.\n  return { enabled: value !== false };\n}\n\nfunction resolveRates(rates: readonly number[] | undefined): readonly number[] {\n  if (!rates) return DEFAULT_PLAYBACK_RATES;\n  // `playbackRate` n'accepte que des nombres finis strictement positifs ; une\n  // valeur invalide ferait échouer l'écriture sur l'élément, silencieusement.\n  const cleaned = Array.from(new Set(rates.filter((rate) => Number.isFinite(rate) && rate > 0)));\n  if (cleaned.length === 0) return DEFAULT_PLAYBACK_RATES;\n  return Object.freeze(cleaned.sort((a, b) => a - b));\n}\n\nexport function resolveControlsOptions(options: ControlsOptions = {}): ResolvedControlsOptions {\n  const { playbackRate, scrubber } = options;\n  const rateOptions = typeof playbackRate === \"object\" ? playbackRate : undefined;\n  const scrubberOptions = typeof scrubber === \"object\" ? scrubber : undefined;\n\n  return {\n    visibility: options.visibility ?? \"auto\",\n    autoHideDelay: options.autoHideDelay ?? DEFAULT_AUTO_HIDE_DELAY,\n    play: toggle(options.play),\n    scrubber: {\n      enabled: scrubber !== false,\n      chapters: scrubberOptions?.chapters !== false,\n    },\n    time: toggle(options.time),\n    volume: toggle(options.volume),\n    fullscreen: toggle(options.fullscreen),\n    pictureInPicture: toggle(options.pictureInPicture),\n    chapters: toggle(options.chapters),\n    playbackRate: {\n      enabled: playbackRate !== false,\n      rates: resolveRates(rateOptions?.rates),\n    },\n    quality: toggle(options.quality),\n    live: toggle(options.live),\n    keyboard: toggle(options.keyboard),\n  };\n}\n",
      "type": "registry:ui",
      "target": "@ui/video-player/controls-options.ts"
    },
    {
      "path": "registry/videocn/controls-context.tsx",
      "content": "\"use client\";\n\nimport { createContext, useContext, useEffect, type ReactNode } from \"react\";\n\nimport type { ResolvedControlsOptions } from \"./controls-options\";\n\n/**\n * Ce que la barre distribue à ses contrôles. Conséquence directe : **aucun\n * contrôle ne reçoit de prop**. Chacun lit ce dont il a besoin et se rend\n * `null` si son option est désactivée — ce qui les rend mémoïsables, et ce qui\n * permet d'en écrire plusieurs en parallèle sans contrat entre eux.\n *\n * Trois contextes et non un seul, pour la même raison que dans\n * `player-context.tsx` : ils ne changent pas au même rythme. Les options ne\n * changent jamais, le verrou non plus, la visibilité bascule souvent. Un menu\n * qui pose un verrou n'a aucune raison de se re-rendre parce que la barre vient\n * d'apparaître.\n */\n\nconst ControlsOptionsContext = createContext<ResolvedControlsOptions | null>(null);\nconst ControlsVisibleContext = createContext<boolean | null>(null);\nconst ControlsHoldContext = createContext<(() => () => void) | null>(null);\n\nexport interface ControlsProviderProps {\n  options: ResolvedControlsOptions;\n  visible: boolean;\n  /** Pose un verrou et renvoie sa libération. Référence stable. */\n  holdVisible: () => () => void;\n  children: ReactNode;\n}\n\nexport function ControlsProvider({\n  options,\n  visible,\n  holdVisible,\n  children,\n}: ControlsProviderProps) {\n  return (\n    <ControlsOptionsContext.Provider value={options}>\n      <ControlsHoldContext.Provider value={holdVisible}>\n        <ControlsVisibleContext.Provider value={visible}>\n          {children}\n        </ControlsVisibleContext.Provider>\n      </ControlsHoldContext.Provider>\n    </ControlsOptionsContext.Provider>\n  );\n}\n\nfunction missing(): never {\n  throw new Error(\"The player controls must be rendered inside <VideoCn>.\");\n}\n\nexport function useControlsOptions(): ResolvedControlsOptions {\n  return useContext(ControlsOptionsContext) ?? missing();\n}\n\nexport function useControlsVisible(): boolean {\n  const visible = useContext(ControlsVisibleContext);\n  if (visible === null) missing();\n  return visible;\n}\n\n/**\n * Empêche l'auto-masquage tant que `active` est vrai — un menu ouvert ne peut\n * pas voir sa barre disparaître sous lui. Un compteur plutôt qu'un booléen\n * partagé : les phases 4 à 6 ajouteront d'autres menus, et deux verrous posés\n * en même temps ne doivent pas s'écraser l'un l'autre.\n */\nexport function useHoldControlsVisible(active: boolean): void {\n  const holdVisible = useContext(ControlsHoldContext) ?? missing();\n\n  useEffect(() => {\n    if (!active) return;\n    return holdVisible();\n  }, [active, holdVisible]);\n}\n",
      "type": "registry:ui",
      "target": "@ui/video-player/controls-context.tsx"
    },
    {
      "path": "registry/videocn/chapters-context.tsx",
      "content": "\"use client\";\n\nimport { createContext, useCallback, useContext, useState, type ReactNode } from \"react\";\n\nimport {\n  findChapterIndex,\n  NO_CHAPTERS,\n  resolveChapters,\n  type Chapter,\n  type ResolvedChapter,\n} from \"./chapters\";\nimport { usePlayerValue, usePlayheadValue } from \"./player-context\";\nimport { displayedTime, type PlayheadSnapshot } from \"./playhead-store\";\n\n/**\n * La liste des chapitres, distribuée déjà normalisée.\n *\n * Le contexte ne porte pas la prop brute mais son résultat : la normalisation\n * a lieu une fois, ici, et non dans chaque consommateur. La piste et le menu\n * reçoivent alors **la même référence**, ce qui les rend mémoïsables et permet\n * à l'effet qui peint les segments de ne tourner qu'au changement de vidéo.\n */\n\nconst ChaptersContext = createContext<readonly ResolvedChapter[] | null>(null);\n\n/**\n * Ce qui identifie une liste, indépendamment de la référence du tableau.\n *\n * `<VideoCn chapters={[…]} />` fabrique un tableau neuf à chaque rendu de la\n * page hôte — c'est la façon normale d'écrire du JSX, et on ne va pas demander\n * à l'utilisateur de mémoïser sa liste pour que le lecteur se tienne bien. On\n * compare donc le contenu, pas l'adresse.\n */\nfunction signChapters(chapters: readonly Chapter[] | undefined, duration: number): string {\n  if (!chapters || chapters.length === 0) return `${duration}`;\n  // Deux séparateurs qu'un libellé ne peut pas contenir : sans eux, un titre\n  // portant le séparateur pourrait imiter la signature d'une autre liste.\n  return `${duration}\\u0001${chapters.map((chapter) => `${chapter?.time}\\u0000${chapter?.label}`).join(\"\\u0001\")}`;\n}\n\nexport interface ChaptersProviderProps {\n  chapters?: readonly Chapter[];\n  children: ReactNode;\n}\n\nexport function ChaptersProvider({ chapters, children }: ChaptersProviderProps) {\n  // La durée est ce qui donne sa fin au dernier chapitre. Elle arrive après le\n  // montage, et change une fois par source : s'y abonner ici coûte un rendu de\n  // la barre par vidéo.\n  const duration = usePlayerValue((state) => state.duration);\n  const signature = signChapters(chapters, duration);\n\n  // L'état ajusté pendant le rendu, motif documenté par React. C'est ce qui\n  // donne une **référence stable** entre deux rendus de l'hôte : un `useMemo`\n  // sur la prop rendrait un tableau neuf à chaque fois — la prop en est un —,\n  // et un effet ferait rendre une fois la liste vide avant la vraie.\n  const [cache, setCache] = useState(() => ({\n    signature,\n    resolved: resolveChapters(chapters, duration),\n  }));\n\n  // Recalculée et rendue dans la foulée : la liste juste part dans le contexte\n  // dès ce rendu-ci, et l'état la garde pour les suivants. Le rendu que\n  // provoque `setCache` retrouve alors cette même référence et ne réveille\n  // personne.\n  let resolved = cache.resolved;\n  if (cache.signature !== signature) {\n    resolved = resolveChapters(chapters, duration);\n    setCache({ signature, resolved });\n  }\n\n  return <ChaptersContext.Provider value={resolved}>{children}</ChaptersContext.Provider>;\n}\n\n/**\n * La liste normalisée. Vide quand il n'y a rien à découper — pas de prop,\n * durée inconnue, ou flux en direct.\n */\nexport function useChapters(): readonly ResolvedChapter[] {\n  return useContext(ChaptersContext) ?? NO_CHAPTERS;\n}\n\n/**\n * L'index du chapitre en cours, ou `-1`.\n *\n * Le sélecteur rend un nombre, donc l'abonnement ne réveille son lecteur qu'au\n * franchissement d'une frontière de chapitre — pas soixante fois par seconde.\n * C'est la même discipline que partout ailleurs dans le lecteur : on s'abonne à\n * ce qu'on affiche, jamais à la tête de lecture brute.\n *\n * `displayedTime` et non `currentTime` : pendant un glissement, le menu doit\n * suivre le doigt, comme le reste.\n */\nexport function useActiveChapterIndex(): number {\n  const chapters = useChapters();\n  const select = useCallback(\n    (snapshot: PlayheadSnapshot) => findChapterIndex(chapters, displayedTime(snapshot)),\n    [chapters],\n  );\n  return usePlayheadValue(select);\n}\n",
      "type": "registry:ui",
      "target": "@ui/video-player/chapters-context.tsx"
    },
    {
      "path": "registry/videocn/use-controls-visibility.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState, type RefObject } from \"react\";\n\n/**\n * Quand la barre se montre, quand elle s'efface. Tout est ici et nulle part\n * ailleurs : un seul endroit décide, et les contrôles ne font que lire.\n *\n * La règle tient en une phrase : **on montre à la moindre activité, on ne\n * masque qu'à la réunion de toutes les conditions**. Montrer est toujours sûr ;\n * masquer ne l'est jamais — une barre qui disparaît sous la souris, sous le\n * focus clavier ou sous un menu ouvert est un bug, pas une animation.\n */\n\nexport interface UseControlsVisibilityOptions {\n  containerRef: RefObject<HTMLElement | null>;\n  /** La vidéo est à l'arrêt : la barre ne doit alors jamais se masquer. */\n  paused: boolean;\n  visibility: \"auto\" | \"always\" | \"never\";\n  autoHideDelay: number;\n}\n\nexport interface ControlsVisibility {\n  visible: boolean;\n  /** Pose un verrou, renvoie sa libération. Référence stable. */\n  holdVisible: () => () => void;\n  /**\n   * Signale une activité venue d'ailleurs que du pointeur ou du focus — un\n   * raccourci clavier : la barre apparaît et le minuteur repart. Référence\n   * stable. Sans effet hors du mode `auto`.\n   */\n  reveal: () => void;\n}\n\n/**\n * Sur un écran tactile, un `pointerdown` bascule la visibilité : sans survol,\n * c'est le seul geste qui permette de faire disparaître la barre. Mais un doigt\n * qui vise un bouton *de* la barre ne demande pas à la faire disparaître — il\n * demande à appuyer dessus. D'où ce test sur la cible du geste.\n */\nconst CONTROLS_SELECTOR = '[data-slot=\"video-player-controls\"]';\n\n/**\n * Seul le focus **clavier** retient la barre : `:focus-visible`, et non `:focus`.\n * C'est le correctif d'un bug de la phase 1. Sous Chrome, un clic souris sur un\n * bouton lui donne le focus, qu'il garde jusqu'au prochain clic ailleurs ; en\n * comptant tout focus, la barre ne se masquait plus d'ici là. Avec le scrubber,\n * chaque recherche à la souris laisserait le focus sur le curseur, et la barre\n * resterait affichée en permanence. La classe de masquage de la barre suit la\n * même règle (`not-has-focus-visible`) : le CSS et ce hook doivent tomber\n * d'accord.\n */\nfunction isKeyboardFocused(element: Element): boolean {\n  return element.matches(\":focus-visible\");\n}\n\n/**\n * L'élément actif est dans le conteneur, et il y est arrivé au clavier.\n *\n * Le conteneur lui-même ne compte pas. Il prend le focus au clic, pour que les\n * raccourcis lui parviennent, et Chrome le passe en `:focus-visible` à la\n * première touche frappée — vérifié. Sans cette exclusion, un seul raccourci\n * empêcherait la barre de se masquer tant que le focus reste là.\n */\nfunction hasKeyboardFocusWithin(container: HTMLElement): boolean {\n  const active = document.activeElement;\n  return (\n    active !== null &&\n    active !== container &&\n    container.contains(active) &&\n    isKeyboardFocused(active)\n  );\n}\n\nexport function useControlsVisibility(options: UseControlsVisibilityOptions): ControlsVisibility {\n  const { containerRef, paused, visibility, autoHideDelay } = options;\n\n  const [autoVisible, setAutoVisible] = useState(true);\n  /**\n   * Un compteur et non un booléen : les phases suivantes ajouteront d'autres\n   * menus, et deux verrous posés en même temps ne doivent pas s'écraser l'un\n   * l'autre. En état plutôt qu'en ref, parce que c'est le retour à zéro qui\n   * doit relancer le minuteur.\n   */\n  const [holds, setHolds] = useState(0);\n  /** Le focus clavier est dans le conteneur — voir `isKeyboardFocused`. */\n  const [keyboardFocus, setKeyboardFocus] = useState(false);\n\n  /**\n   * Réarme le minuteur de masquage sans passer par l'état. Posé par l'effet du\n   * minuteur quand toutes les conditions de masquage sont réunies, `null`\n   * sinon.\n   *\n   * C'est le correctif d'un second bug de la phase 1. Un mouvement de souris\n   * appelait `setAutoVisible(true)` ; quand la barre était déjà visible, la\n   * valeur ne changeait pas, l'effet ne se relançait pas, et le minuteur\n   * n'était **jamais** réarmé : la barre disparaissait trois secondes après être\n   * apparue, même sous une souris qui ne s'arrêtait pas, puis revenait au\n   * mouvement suivant. Un clignotement toutes les trois secondes. Passer par un\n   * compteur d'activité en état l'aurait corrigé au prix d'un rendu du lecteur\n   * entier à chaque mouvement, soixante fois par seconde : le minuteur se\n   * réarme donc hors de React, et l'état ne change qu'aux vraies transitions.\n   */\n  const rearmRef = useRef<(() => void) | null>(null);\n\n  /**\n   * La souris a quitté le lecteur pendant qu'un verrou tenait la barre : elle\n   * disparaîtra dès qu'il tombera, sans attendre le délai.\n   *\n   * C'est le correctif d'un troisième bug de la phase 1. Sortir par le bas,\n   * c'est traverser la barre, et le survol de la barre est un verrou. Le\n   * `pointerleave` du conteneur arrive avant que React n'ait libéré ce verrou —\n   * il le fait dans un effet, après le rendu —, trouvait donc la barre encore\n   * retenue et renonçait ; quand le verrou tombait enfin, c'est le minuteur\n   * complet qui repartait. Mesuré : trois secondes pile entre la sortie et le\n   * masquage. Le même drapeau couvre un glissement relâché hors du lecteur et\n   * un menu refermé une fois la souris partie.\n   *\n   * Une ref et non un état : il ne décide de rien seul, il ne fait que choisir\n   * le délai du prochain minuteur, que la chute du verrou relance déjà.\n   */\n  const hideOnReleaseRef = useRef(false);\n\n  /**\n   * Ajustement pendant le rendu, et non dans un effet : la barre doit être là\n   * dans la frame où la lecture s'arrête. Un effet la ferait apparaître une\n   * frame plus tard, ce qui se voit exactement au moment où l'utilisateur\n   * regarde l'endroit où elle doit apparaître.\n   */\n  const [wasPaused, setWasPaused] = useState(paused);\n  if (wasPaused !== paused) {\n    setWasPaused(paused);\n    if (paused) setAutoVisible(true);\n  }\n\n  const reveal = useCallback(() => {\n    hideOnReleaseRef.current = false;\n    // Masquée : l'état change, et l'effet du minuteur repart de lui-même.\n    // Déjà visible : l'état ne bouge pas, et c'est le réarmement qui compte.\n    setAutoVisible(true);\n    rearmRef.current?.();\n  }, []);\n\n  const holdVisible = useCallback(() => {\n    setHolds((count) => count + 1);\n    setAutoVisible(true);\n    let released = false;\n    return () => {\n      // Le nettoyage d'un effet peut être rejoué ; le compteur ne doit pas\n      // passer sous zéro, sans quoi le verrou suivant ne bloquerait plus rien.\n      if (released) return;\n      released = true;\n      setHolds((count) => count - 1);\n    };\n  }, []);\n\n  useEffect(() => {\n    // `always` et `never` n'installent rien : ni écouteur, ni minuteur.\n    if (visibility !== \"auto\") return;\n    const container = containerRef.current;\n    if (!container) return;\n\n    const handlePointerMove = (event: PointerEvent) => {\n      // Masquer le curseur avec la barre provoque un `pointermove` sans\n      // déplacement. Sans ce filtre, réafficher le curseur relancerait le cycle\n      // tout seul : la barre clignoterait indéfiniment sur une souris immobile.\n      if (event.movementX === 0 && event.movementY === 0) return;\n      reveal();\n    };\n\n    const handlePointerDown = (event: PointerEvent) => {\n      const target = event.target;\n      const onControls = target instanceof Element && target.closest(CONTROLS_SELECTOR) !== null;\n      if (event.pointerType !== \"mouse\" && !onControls) {\n        setAutoVisible((current) => !current);\n        return;\n      }\n      reveal();\n    };\n\n    const handlePointerLeave = (event: PointerEvent) => {\n      // La souris a quitté le lecteur : il n'y a plus rien à attendre. Un doigt,\n      // lui, « quitte » au relâchement — ce n'est pas un départ.\n      if (event.pointerType !== \"mouse\") return;\n      if (autoHideDelay <= 0 || paused || keyboardFocus) return;\n      if (hasKeyboardFocusWithin(container)) return;\n      if (holds > 0) {\n        hideOnReleaseRef.current = true;\n        return;\n      }\n      setAutoVisible(false);\n    };\n\n    const handleFocusIn = (event: FocusEvent) => {\n      // Un focus souris remet l'indicateur à faux au lieu d'être ignoré : si le\n      // clavier avait posé le focus sur un bouton et qu'un clic le déplace sur\n      // un autre, aucun `focusout` ne sort du conteneur pour le remettre à\n      // zéro, et la barre resterait retenue par un focus qui n'est plus visible.\n      const target = event.target;\n      // Le conteneur est exclu, pour la raison dite sur `hasKeyboardFocusWithin`.\n      const keyboard = target instanceof Element && target !== container && isKeyboardFocused(target);\n      setKeyboardFocus(keyboard);\n      if (keyboard) reveal();\n    };\n\n    const handleFocusOut = (event: FocusEvent) => {\n      // Un déplacement de focus d'un bouton à l'autre émet `focusout` puis\n      // `focusin` : sans ce test, le minuteur repartirait à chaque tabulation.\n      const next = event.relatedTarget;\n      if (next instanceof Node && container.contains(next)) return;\n      setKeyboardFocus(false);\n    };\n\n    container.addEventListener(\"pointermove\", handlePointerMove);\n    container.addEventListener(\"pointerdown\", handlePointerDown);\n    container.addEventListener(\"pointerleave\", handlePointerLeave);\n    container.addEventListener(\"focusin\", handleFocusIn);\n    container.addEventListener(\"focusout\", handleFocusOut);\n\n    return () => {\n      container.removeEventListener(\"pointermove\", handlePointerMove);\n      container.removeEventListener(\"pointerdown\", handlePointerDown);\n      container.removeEventListener(\"pointerleave\", handlePointerLeave);\n      container.removeEventListener(\"focusin\", handleFocusIn);\n      container.removeEventListener(\"focusout\", handleFocusOut);\n    };\n  }, [autoHideDelay, containerRef, holds, keyboardFocus, paused, reveal, visibility]);\n\n  useEffect(() => {\n    if (visibility !== \"auto\") return;\n    if (!autoVisible) return;\n    // `autoHideDelay: 0` dit « ne masque jamais » sans renoncer au reste du\n    // comportement automatique.\n    if (autoHideDelay <= 0) return;\n    // Une vidéo arrêtée ne cache rien derrière sa barre.\n    if (paused) return;\n    if (holds > 0) return;\n    if (keyboardFocus) return;\n\n    let timer: ReturnType<typeof setTimeout> | undefined;\n    const arm = () => {\n      clearTimeout(timer);\n      // Lu à chaque armement : le drapeau se lève pendant que la barre est\n      // retenue, et c'est la chute du verrou qui relance cet effet.\n      const delay = hideOnReleaseRef.current ? 0 : autoHideDelay;\n      timer = setTimeout(() => {\n        hideOnReleaseRef.current = false;\n        // Dernier mot au DOM : un focus posé par le code de l'hôte n'est pas\n        // passé par `focusin` ici, et masquer un élément focalisé le rendrait\n        // invisible sans le rendre inatteignable — le pire des deux. Focus\n        // clavier seulement, là aussi : voir `isKeyboardFocused`.\n        const container = containerRef.current;\n        if (container && hasKeyboardFocusWithin(container)) return;\n        setAutoVisible(false);\n      }, delay);\n    };\n    arm();\n    rearmRef.current = arm;\n\n    return () => {\n      clearTimeout(timer);\n      if (rearmRef.current === arm) rearmRef.current = null;\n    };\n  }, [autoHideDelay, autoVisible, containerRef, holds, keyboardFocus, paused, visibility]);\n\n  return {\n    /**\n     * `never` renvoie `true` comme `always`, et ce n'est pas une inattention :\n     * il n'y a pas de barre à masquer, et le conteneur masque le curseur avec\n     * elle. Renvoyer `false` cacherait le curseur pour de bon.\n     */\n    visible: visibility === \"auto\" ? autoVisible : true,\n    holdVisible,\n    reveal,\n  };\n}\n",
      "type": "registry:ui",
      "target": "@ui/video-player/use-controls-visibility.ts"
    },
    {
      "path": "registry/videocn/use-keyboard-shortcuts.ts",
      "content": "\"use client\";\n\nimport { useCallback, type KeyboardEvent as ReactKeyboardEvent } from \"react\";\n\nimport { DEFAULT_SEEK_STEP, DEFAULT_VOLUME_STEP } from \"./controls-options\";\nimport type { PlayerState, PlayerStateStore } from \"./player-state-store\";\nimport type { PlayerActions } from \"./use-player\";\n\n/**\n * La keymap du lecteur, active tant que le focus est dans le lecteur, et\n * nulle part ailleurs : une page qui porte deux lecteurs ne les pilote pas\n * ensemble, et les champs de la page hôte ne sont jamais concernés.\n *\n * **Un gestionnaire React, jamais un écouteur natif.** React délègue ses\n * événements à sa racine. Un écouteur natif posé sur le conteneur recevrait la\n * touche avant que le curseur ou le menu ne l'aient traitée et arrêtée, et une\n * flèche sur le scrubber agirait deux fois. Un `onKeyDown`, lui, est arrêté\n * par leur `stopPropagation()`. `defaultPrevented` est le filet pour ce qui\n * empêcherait l'action par défaut sans arrêter la propagation.\n *\n * Le conteneur prend le focus au clic (`tabIndex={-1}`) : sans ça, un clic sur\n * l'image ne focaliserait rien, et aucune touche n'arriverait jusqu'ici.\n */\n\ninterface Shortcut {\n  /**\n   * Une bascule ne se répète pas quand on laisse la touche enfoncée : la\n   * lecture ou le plein écran clignoteraient à la cadence du clavier.\n   */\n  toggle: boolean;\n  run(actions: PlayerActions, state: PlayerState): void;\n}\n\nconst TOGGLE_PLAY: Shortcut = {\n  toggle: true,\n  run: (actions) => actions.togglePlay(),\n};\n\nconst TOGGLE_MUTED: Shortcut = {\n  toggle: true,\n  run: (actions) => actions.toggleMuted(),\n};\n\nconst TOGGLE_FULLSCREEN: Shortcut = {\n  toggle: true,\n  run: (actions, state) => {\n    if (state.canFullscreen) actions.toggleFullscreen();\n  },\n};\n\nfunction seekBy(seconds: number): Shortcut {\n  return {\n    toggle: false,\n    run: (actions, state) => {\n      // Rien à parcourir tant que la durée est inconnue. `Infinity` passe :\n      // en direct, reculer dans la fenêtre DVR a un sens.\n      if (state.duration > 0) actions.seekBy(seconds);\n    },\n  };\n}\n\nfunction stepVolume(delta: number): Shortcut {\n  return { toggle: false, run: (actions) => actions.stepVolume(delta) };\n}\n\nfunction seekToFraction(fraction: number): Shortcut {\n  return {\n    toggle: false,\n    run: (actions, state) => {\n      // Une fraction d'une durée inconnue ou infinie ne désigne aucun instant.\n      if (Number.isFinite(state.duration) && state.duration > 0) {\n        actions.seek(state.duration * fraction);\n      }\n    },\n  };\n}\n\n/**\n * Le chiffre d'abord par sa valeur : c'est elle qui est gravée sur la touche,\n * sur le pavé numérique comme sur la rangée du haut d'un clavier QWERTY. À\n * défaut, par la position sur la rangée du haut : en AZERTY, la touche `1`\n * donne `&` sans Maj, et `key` ne vaudrait jamais `1`.\n */\nfunction digitOf(event: ReactKeyboardEvent): number | null {\n  if (/^[0-9]$/.test(event.key)) return Number(event.key);\n  const match = /^Digit([0-9])$/.exec(event.code);\n  return match ? Number(match[1]) : null;\n}\n\n/**\n * Les flèches par leur nom. Pas de miroir en RTL : le scrubber ne se retourne\n * pas (`dir=\"ltr\"`), donc `←` recule partout. Les lettres par leur valeur et\n * non par leur position : `m` est là où la disposition l'a mis, et Verr. Maj\n * ne doit rien changer.\n */\nfunction resolveShortcut(event: ReactKeyboardEvent): Shortcut | null {\n  switch (event.key) {\n    case \" \":\n      return TOGGLE_PLAY;\n    case \"ArrowLeft\":\n      return seekBy(-DEFAULT_SEEK_STEP);\n    case \"ArrowRight\":\n      return seekBy(DEFAULT_SEEK_STEP);\n    case \"ArrowUp\":\n      return stepVolume(DEFAULT_VOLUME_STEP);\n    case \"ArrowDown\":\n      return stepVolume(-DEFAULT_VOLUME_STEP);\n    default:\n      break;\n  }\n  switch (event.key.toLowerCase()) {\n    case \"k\":\n      return TOGGLE_PLAY;\n    case \"m\":\n      return TOGGLE_MUTED;\n    case \"f\":\n      return TOGGLE_FULLSCREEN;\n    default:\n      break;\n  }\n  const digit = digitOf(event);\n  return digit === null ? null : seekToFraction(digit / 10);\n}\n\n/**\n * Un champ où l'on tape. Aucun n'existe dans le lecteur aujourd'hui, et ceux\n * de la page hôte n'envoient jamais leurs touches jusqu'au conteneur ; le\n * filtre est là pour le jour où le lecteur en aura un.\n */\nfunction isEditable(target: EventTarget): boolean {\n  if (!(target instanceof HTMLElement)) return false;\n  return target.isContentEditable || target.matches(\"input, textarea, select\");\n}\n\n/** `Espace` y déclenche le bouton lui-même, et c'est lui qui doit gagner. */\nfunction isButton(target: EventTarget): boolean {\n  return target instanceof Element && target.matches('button, [role=\"button\"]');\n}\n\nexport interface UseKeyboardShortcutsOptions {\n  enabled: boolean;\n  /** Lu à la frappe et jamais par abonnement : la keymap ne provoque aucun rendu. */\n  store: PlayerStateStore;\n  actions: PlayerActions;\n  /** Un raccourci est une activité : la barre apparaît, et on voit son effet. */\n  reveal: () => void;\n}\n\n/** Le gestionnaire à poser sur le conteneur, ou `undefined` si la keymap est coupée. */\nexport function useKeyboardShortcuts(\n  options: UseKeyboardShortcutsOptions,\n): ((event: ReactKeyboardEvent<HTMLElement>) => void) | undefined {\n  const { enabled, store, actions, reveal } = options;\n\n  const handleKeyDown = useCallback(\n    (event: ReactKeyboardEvent<HTMLElement>) => {\n      if (event.defaultPrevented) return;\n      // Les combinaisons appartiennent au navigateur et au système : `Cmd+0`\n      // remet le zoom à 100 %, `Cmd+F` cherche dans la page. `Shift` passe :\n      // c'est lui qui donne les chiffres en AZERTY.\n      if (event.altKey || event.ctrlKey || event.metaKey) return;\n      // Une composition en cours (IME) : la touche forme un caractère, elle ne\n      // commande rien.\n      if (event.nativeEvent.isComposing) return;\n      if (isEditable(event.target)) return;\n      if (event.key === \" \" && isButton(event.target)) return;\n\n      const shortcut = resolveShortcut(event);\n      if (!shortcut) return;\n\n      // Le lecteur a le focus et la touche est à lui : ni défilement de la\n      // page, ni composant parent qui réagirait aussi à `←`.\n      event.preventDefault();\n      event.stopPropagation();\n      // La répétition est avalée — sans quoi `Espace` enfoncé ferait défiler\n      // la page —, mais une bascule n'agit qu'une fois.\n      if (event.repeat && shortcut.toggle) return;\n\n      shortcut.run(actions, store.getSnapshot());\n      reveal();\n    },\n    [actions, reveal, store],\n  );\n\n  return enabled ? handleKeyDown : undefined;\n}\n",
      "type": "registry:ui",
      "target": "@ui/video-player/use-keyboard-shortcuts.ts"
    },
    {
      "path": "registry/videocn/video-cn.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  type PointerEvent as ReactPointerEvent,\n  type Ref,\n} from \"react\";\n\n/**\n * `cn` vient du paquet npm et non de l'alias `utils` : `lib/utils.ts` n'est plus\n * qu'une réexportation chez shadcn, les primitives générées importent déjà\n * depuis `\"cn\"`, et rien ne garantit que le fichier existe chez le consommateur\n * — vérifié en le retirant d'un projet de test, l'installation n'en dit rien.\n */\nimport { cn } from \"cn\";\n\nimport type { Chapter } from \"./chapters\";\nimport { ChaptersProvider } from \"./chapters-context\";\nimport { ControlsProvider } from \"./controls-context\";\nimport { resolveControlsOptions, type ControlsOptions } from \"./controls-options\";\nimport { PlayerControls } from \"./player-controls\";\nimport { PlayerProvider, usePlayerStoreValue } from \"./player-context\";\nimport type { SourceType } from \"./player-engine\";\nimport { useControlsVisibility } from \"./use-controls-visibility\";\nimport { useKeyboardShortcuts } from \"./use-keyboard-shortcuts\";\nimport { usePlayer } from \"./use-player\";\n\nexport interface VideoCnProps {\n  src: string;\n  /**\n   * Échappement pour les URL signées, sans extension ou trompeuses, que la\n   * détection automatique ne saurait pas lire.\n   */\n  type?: SourceType;\n  poster?: string;\n  /**\n   * Les chapitres, un début et un titre par entrée, le temps en secondes.\n   *\n   * L'ordre n'a pas d'importance, les doublons et les valeurs hors de la vidéo\n   * sont écartés, et le premier chapitre est ramené à zéro — un trou en tête de\n   * barre ne voudrait rien dire. Sans effet sur un flux en direct, où un\n   * chapitre n'aurait ni fin ni place fixe.\n   */\n  chapters?: readonly Chapter[];\n  autoPlay?: boolean;\n  loop?: boolean;\n  /**\n   * Volume de départ, entre 0 et 1. Valeur *initiale* : le lecteur détient son\n   * volume et le garde. Repasser une autre valeur ensuite ne le repilote pas.\n   */\n  defaultVolume?: number;\n  defaultMuted?: boolean;\n  /** Ce que la barre affiche et comment elle se comporte. Voir `ControlsOptions`. */\n  controls?: ControlsOptions;\n  className?: string;\n  /** Transmis à l'élément `<video>` interne. */\n  ref?: Ref<HTMLVideoElement>;\n}\n\n/**\n * Un clic bascule la lecture, un double-clic le plein écran : le premier doit\n * donc attendre de savoir s'il est seul. Ce délai est la rançon du geste — trop\n * court, le double-clic lance aussi la lecture ; trop long, le clic traîne.\n */\nconst DOUBLE_CLICK_WINDOW = 250;\n\n/**\n * Le composant racine. Il détient l'état, le distribue, et rend le conteneur\n * dans lequel vivent les contrôles.\n *\n * Il n'accepte pas de `children` : la barre est toujours la nôtre, et c'est aux\n * props de la piloter. Le lecteur s'installe et fonctionne — masquer un\n * contrôle ou changer un comportement doit rester une prop, jamais une\n * modification à faire à la main dans le code livré. Le découpage en un fichier\n * par contrôle est là pour la lisibilité, pas pour sous-traiter le travail.\n */\nexport function VideoCn({\n  src,\n  type,\n  poster,\n  chapters,\n  autoPlay,\n  loop,\n  defaultVolume,\n  defaultMuted,\n  controls,\n  className,\n  ref,\n}: VideoCnProps) {\n  // Le plein écran est demandé sur le conteneur, pas sur le `<video>` : sinon\n  // les contrôles, qui sont à côté de la vidéo et non dedans, disparaîtraient.\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  const player = usePlayer({\n    src,\n    type,\n    defaultVolume,\n    defaultMuted,\n    containerRef,\n  });\n\n  const { actions } = player;\n  const paused = usePlayerStoreValue(player.store, (state) => state.paused);\n  const isFullscreen = usePlayerStoreValue(player.store, (state) => state.isFullscreen);\n  const { togglePlay, toggleFullscreen } = actions;\n\n  // Résolue une fois : l'objet part dans un contexte, et en fabriquer un\n  // nouveau à chaque rendu re-rendrait tous les contrôles pour rien.\n  const controlsOptions = useMemo(() => resolveControlsOptions(controls), [controls]);\n\n  const { visible, holdVisible, reveal } = useControlsVisibility({\n    containerRef,\n    paused,\n    visibility: controlsOptions.visibility,\n    autoHideDelay: controlsOptions.autoHideDelay,\n  });\n\n  const handleKeyDown = useKeyboardShortcuts({\n    enabled: controlsOptions.keyboard.enabled,\n    store: player.store,\n    actions,\n    reveal,\n  });\n\n  // La ref du consommateur passe par une ref à nous, jamais par les\n  // dépendances : une callback ref écrite en ligne change à chaque rendu, et\n  // React détacherait puis rattacherait l'élément à chaque changement d'état.\n  const forwardedRef = useRef(ref);\n  useEffect(() => {\n    forwardedRef.current = ref;\n  }, [ref]);\n\n  const attachPlayerVideo = player.ref;\n  const attachVideo = useCallback(\n    (node: HTMLVideoElement | null) => {\n      attachPlayerVideo(node);\n      const forwarded = forwardedRef.current;\n      if (typeof forwarded === \"function\") {\n        forwarded(node);\n      } else if (forwarded) {\n        forwarded.current = node;\n      }\n    },\n    [attachPlayerVideo],\n  );\n\n  /**\n   * Les deux gestes sur l'image. Ils sont posés sur le `<video>` et non sur le\n   * conteneur : sur le conteneur, un clic dans la barre déclencherait la\n   * lecture.\n   *\n   * Réservés à la souris. Au doigt, un appui bascule déjà la barre — la seule\n   * façon de la faire disparaître sans survol — et lui faire aussi mettre la\n   * vidéo en pause donnerait deux effets pour un geste.\n   */\n  const pointerTypeRef = useRef(\"mouse\");\n  const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  const handlePointerDown = useCallback((event: ReactPointerEvent<HTMLVideoElement>) => {\n    pointerTypeRef.current = event.pointerType;\n  }, []);\n\n  const handleClick = useCallback(() => {\n    if (pointerTypeRef.current !== \"mouse\") return;\n    // Le deuxième clic d'un double-clic ne réarme rien : le minuteur en cours\n    // sera annulé par le `dblclick` qui suit.\n    if (clickTimerRef.current !== null) return;\n    clickTimerRef.current = setTimeout(() => {\n      clickTimerRef.current = null;\n      togglePlay();\n    }, DOUBLE_CLICK_WINDOW);\n  }, [togglePlay]);\n\n  const handleDoubleClick = useCallback(() => {\n    if (pointerTypeRef.current !== \"mouse\") return;\n    if (clickTimerRef.current !== null) {\n      clearTimeout(clickTimerRef.current);\n      clickTimerRef.current = null;\n    }\n    toggleFullscreen();\n  }, [toggleFullscreen]);\n\n  useEffect(() => {\n    return () => {\n      if (clickTimerRef.current !== null) clearTimeout(clickTimerRef.current);\n    };\n  }, []);\n\n  // `\"\"` plutôt que `\"true\"` : seule la présence de l'attribut compte pour les\n  // variantes Tailwind, et `undefined` le retire vraiment du DOM.\n  const fullscreenAttribute = isFullscreen ? \"\" : undefined;\n  const hiddenAttribute = visible ? undefined : \"\";\n\n  return (\n    <PlayerProvider value={player}>\n      <div\n        ref={containerRef}\n        // Repère de la hauteur mesurée par les menus : ils bornent leur popup à\n        // la place qui reste dans ce conteneur.\n        data-slot=\"video-player\"\n        data-fullscreen={fullscreenAttribute}\n        // Le curseur se masque avec la barre, et pour la même raison : plus\n        // rien ne doit flotter au-dessus de l'image.\n        data-hidden={hiddenAttribute}\n        // Focalisable au clic, pas à la tabulation : un clic sur l'image donne\n        // le focus au lecteur, et ses raccourcis répondent. Sans raccourcis, le\n        // focus ne servirait à rien.\n        tabIndex={handleKeyDown ? -1 : undefined}\n        onKeyDown={handleKeyDown}\n        className={cn(\n          // Le cadre du lecteur est noir, pas thématique : en plein écran,\n          // une vidéo moins haute que l'écran laisse voir ses bandes, et du\n          // blanc y serait aveuglant. Un token, jamais une couleur en dur.\n          \"bg-player-backdrop relative isolate overflow-hidden rounded-lg border\",\n          // Chrome passe le conteneur cliqué en `:focus-visible` à la première\n          // touche frappée, et l'entourerait d'un contour. Il n'est pas dans\n          // l'ordre de tabulation : ce contour ne guiderait personne.\n          \"outline-none\",\n          // En plein écran, le conteneur occupe l'écran entier : sans ça la\n          // vidéo reste collée en haut d'un cadre arrondi et bordé.\n          \"data-fullscreen:flex data-fullscreen:h-full data-fullscreen:items-center data-fullscreen:justify-center data-fullscreen:rounded-none data-fullscreen:border-0\",\n          \"data-hidden:cursor-none\",\n          className,\n        )}\n      >\n        <video\n          ref={attachVideo}\n          // `src` n'est volontairement pas posé ici : c'est le moteur qui\n          // charge la source, et deux écritures concurrentes sur la même\n          // propriété rechargeraient la vidéo à chaque rendu.\n          poster={poster}\n          autoPlay={autoPlay}\n          loop={loop}\n          // Sans `playsInline`, l'iPhone bascule en plein écran natif dès la\n          // lecture et toute notre interface disparaît.\n          playsInline\n          // Assez pour connaître la durée, que le scrubber exige, sans tirer la\n          // vidéo entière à ceux qui ne la liront pas.\n          preload=\"metadata\"\n          data-fullscreen={fullscreenAttribute}\n          // Ni `role` ni `tabIndex` : l'équivalent clavier de ces gestes passe\n          // par les boutons de la barre, qui sont déjà dans l'ordre de\n          // tabulation, et par les raccourcis. Un second point focalisable ne\n          // ferait qu'allonger le parcours sans rien apporter.\n          onPointerDown={handlePointerDown}\n          onClick={handleClick}\n          onDoubleClick={handleDoubleClick}\n          className=\"block h-auto w-full data-fullscreen:h-full data-fullscreen:object-contain\"\n        />\n        {/* Autour des contrôles et non du lecteur entier : les chapitres ne\n            servent qu'à la barre, et ce fournisseur s'abonne à la durée. */}\n        <ChaptersProvider chapters={chapters}>\n          <ControlsProvider options={controlsOptions} visible={visible} holdVisible={holdVisible}>\n            <PlayerControls />\n          </ControlsProvider>\n        </ChaptersProvider>\n      </div>\n    </PlayerProvider>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/video-player/video-cn.tsx"
    },
    {
      "path": "registry/videocn/player-controls.tsx",
      "content": "\"use client\";\n\nimport { useState, type ReactElement } from \"react\";\n\nimport { ChapterMenu } from \"./chapter-menu\";\nimport { useControlsOptions, useControlsVisible, useHoldControlsVisible } from \"./controls-context\";\nimport { FullscreenToggle } from \"./fullscreen-toggle\";\nimport { LiveBadge } from \"./live-badge\";\nimport { PictureInPictureToggle } from \"./picture-in-picture-toggle\";\nimport { PlayToggle } from \"./play-toggle\";\nimport { PlayerScrubber } from \"./player-scrubber\";\nimport { SettingsMenu } from \"./settings-menu\";\nimport { TimeDisplay } from \"./time-display\";\nimport { VolumeControl } from \"./volume-control\";\n\n/**\n * La barre. Elle ne prend aucune prop et ne pilote aucun contrôle : chacun lit\n * le contexte et décide seul de s'afficher. Ajouter un contrôle, c'est ajouter\n * une ligne ici et un fichier à côté.\n */\nexport function PlayerControls(): ReactElement | null {\n  const { visibility } = useControlsOptions();\n  const visible = useControlsVisible();\n\n  // La souris posée sur la barre l'empêche de disparaître. Le verrou passe par\n  // un état plutôt que par deux appels directs : c'est ce que\n  // `useHoldControlsVisible` attend, et le nettoyage de l'effet garantit la\n  // libération même si le `pointerleave` n'arrive jamais — démontage, passage\n  // en plein écran, onglet quitté.\n  const [hovered, setHovered] = useState(false);\n  useHoldControlsVisible(hovered);\n\n  if (visibility === \"never\") return null;\n\n  return (\n    <div\n      data-slot=\"video-player-controls\"\n      // `group` et non `toolbar` : un toolbar impose une navigation aux\n      // flèches, qui entrerait en collision frontale avec la keymap du lecteur\n      // (`←`/`→` pour se déplacer, `↑`/`↓` pour le volume).\n      role=\"group\"\n      aria-label=\"Player controls\"\n      // Masquée en opacité seulement — jamais `hidden`, `inert` ni\n      // `aria-hidden` : la barre doit rester atteignable à la tabulation.\n      // Le masquage est conditionné plutôt que corrigé par une paire de\n      // classes inverses : à spécificité égale, c'est l'ordre de génération\n      // qui tranche, et `data-hidden` sort après. La barre revient donc dès la\n      // frame où le focus arrive, avant tout rendu React.\n      //\n      // Seul le focus **clavier** compte (`focus-visible`). Un clic souris\n      // donne aussi le focus au bouton cliqué, et la barre ne se serait plus\n      // jamais masquée après une recherche dans le scrubber.\n      data-hidden={visible ? undefined : \"\"}\n      onPointerEnter={() => setHovered(true)}\n      onPointerLeave={() => setHovered(false)}\n      // `dark` est délibéré : un voile est toujours sombre, et le `ghost` du\n      // `Button` donnerait sinon du texte sombre sur fond sombre en thème\n      // clair. La barre est un îlot qui résout ses tokens sur la palette\n      // sombre *de l'utilisateur*, sans une seule couleur en dur.\n      //\n      // Sur une seule ligne, et ce n'est pas du laisser-aller : la conversion\n      // RTL du CLI shadcn ajoute un `\\` en fin de chaque ligne d'une chaîne de\n      // classes, caractère qui reste littéral dans un attribut JSX. Écrite sur\n      // plusieurs lignes, cette chaîne perdait quatre classes dans un projet\n      // RTL — le voile et la couleur du texte avec — et les icônes devenaient\n      // presque invisibles sur la vidéo.\n      //\n      // `@container` : la barre est le contexte des requêtes de largeur de ses\n      // contrôles (l'horodatage, qui s'efface sous 30rem quand le volume se\n      // déplie). Elle et non la racine du lecteur, où `container-type` annulerait\n      // la largeur intrinsèque d'un lecteur en `w-fit`.\n      className=\"@container dark absolute inset-x-0 bottom-0 z-10 flex flex-col gap-2 bg-linear-to-t from-player-scrim to-transparent px-3 pt-10 pb-3 text-foreground transition-opacity duration-200 motion-reduce:transition-none data-hidden:not-has-focus-visible:pointer-events-none data-hidden:not-has-focus-visible:opacity-0\"\n    >\n      <PlayerScrubber />\n      <div className=\"flex items-center gap-1\">\n        <PlayToggle />\n        {/* Juste après la lecture : sur un direct, savoir si l'on est au bord\n            vaut autant que savoir si ça joue. Rendue `null` ailleurs. */}\n        <LiveBadge />\n        <VolumeControl />\n        {/* Après le volume et non avant : la largeur du texte change au fil de\n            la lecture, et elle ne doit jamais déplacer une cible cliquable. */}\n        <TimeDisplay />\n        <div className=\"ml-auto flex items-center gap-1\">\n          {/* Avant les réglages : c'est le seul menu qui parle du contenu et\n              non du rendu, et c'est celui qu'on vient chercher le plus\n              souvent. Rendu `null` quand la vidéo n'a pas de chapitres. */}\n          <ChapterMenu />\n          {/* Vitesse et qualité, rangées derrière un seul bouton : deux\n              boutons à libellé ne tenaient pas dans un lecteur étroit. */}\n          <SettingsMenu />\n          <PictureInPictureToggle />\n          <FullscreenToggle />\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/video-player/player-controls.tsx"
    },
    {
      "path": "registry/videocn/player-menu.tsx",
      "content": "\"use client\";\n\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type FocusEvent as ReactFocusEvent,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type ReactNode,\n  type RefObject,\n} from \"react\";\nimport { CheckIcon } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"cn\";\n\nimport { useHoldControlsVisible } from \"./controls-context\";\n\n/**\n * Un menu déroulant écrit à la main, et non le `DropdownMenu` de shadcn.\n *\n * La raison est unique et suffisante : `DropdownMenuContent` code son portail\n * **en dur** vers `document.body`. Or ce qui est porté sur `body` n'est plus\n * rendu dès qu'un autre élément est en plein écran — et le lecteur passe son\n * conteneur en plein écran, justement pour que la barre y survive. Un menu\n * porté sur `body` serait donc invisible exactement là où on en a le plus\n * besoin. Recomposer par en dessous n'est pas une option : les briques\n * (`Positioner`, `Popup`, `Content` nu) ne sont pas exportées, et la structure\n * interne diverge entre `radix` (`Portal > Content`) et `base`\n * (`Portal > Positioner > Popup`).\n *\n * Ce qui est repris, en revanche, ce sont les classes : elles viennent telles\n * quelles du `dropdown-menu` amont, dont le vocabulaire est identique dans les\n * deux styles. Le menu hérite donc du thème shadcn de l'hôte, et l'utilisateur\n * ne voit pas la différence.\n *\n * Le positionnement se passe de portail : le popup est `absolute` dans un\n * parent `relative`, et s'ouvre vers le haut — la seule direction possible pour\n * une barre en bas, et ce qui le fait vivre en plein écran.\n *\n * L'API est composée, faute de mieux : `asChild` et `render` sont interdits\n * ici (leur nom change d'un style à l'autre), donc les pièces se parlent par un\n * contexte interne plutôt qu'en se déléguant leur rendu.\n */\n\n/** Où poser le focus quand le popup s'ouvre. */\ntype InitialFocus = \"checked\" | \"first\" | \"last\";\n\n/**\n * Les items sont retrouvés dans le DOM plutôt que tenus dans un registre :\n * quoi qu'on compose plus tard — un groupe, un fragment, un `map`, un item\n * inséré par une phase suivante — l'ordre du DOM reste l'ordre de navigation,\n * et il n'y a aucun inventaire à maintenir en parallèle.\n */\nconst ITEM_SELECTOR =\n  '[role=\"menuitemradio\"]:not([data-disabled]),[role=\"menuitem\"]:not([data-disabled])';\n\n/**\n * Ce que le popup laisse libre de chaque côté dans sa hauteur mesurée : le\n * `mb-2` qui le décolle de son déclencheur, et autant au-dessus, pour qu'il ne\n * touche pas le bord du lecteur.\n */\nconst POPUP_MARGIN = 16;\n\n/** Au-delà, la frappe suivante repart d'une chaîne vide. */\nconst TYPEAHEAD_RESET_MS = 500;\n\n/**\n * Relevé dans le `dropdown-menu` amont. Seules les valeurs de rayon diffèrent\n * d'un cran entre `radix` et `base` : un jeu unique donne un rendu juste des\n * deux côtés.\n *\n * L'animation de *sortie* est sciemment abandonnée — la reproduire exigerait de\n * garder le nœud monté pendant la fermeture, avec tout ce que ça suppose de\n * pièges au focus. D'où l'absence de classes `data-closed:*`, qui ne\n * s'appliqueraient jamais.\n */\nconst POPUP_CLASSNAME =\n  \"z-50 min-w-32 overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95\";\n\n/**\n * Idem. Le `w-full` est le seul ajout : l'amont rend ses items en `div`, qui\n * remplissent leur ligne d'office ; nous rendons des `button`, pour que le\n * focus DOM puisse réellement s'y poser, et il faut le leur demander.\n *\n * Le `pr-8` réserve la place de l'indicateur coché : rien ne bouge quand la\n * sélection change de ligne.\n */\nconst ITEM_CLASSNAME =\n  \"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\";\n\ninterface PlayerMenuContextValue {\n  open: boolean;\n  triggerId: string;\n  contentId: string;\n  triggerRef: RefObject<HTMLButtonElement | null>;\n  contentRef: RefObject<HTMLDivElement | null>;\n  /** Lu une fois par le popup à son montage, puis sans effet. */\n  initialFocusRef: RefObject<InitialFocus>;\n  openMenu: (initialFocus: InitialFocus) => void;\n  closeMenu: (restoreFocus: boolean) => void;\n}\n\nconst PlayerMenuContext = createContext<PlayerMenuContextValue | null>(null);\n\nfunction useMenu(): PlayerMenuContextValue {\n  const context = useContext(PlayerMenuContext);\n  if (!context) {\n    throw new Error(\"The menu parts must be rendered inside <PlayerMenu>.\");\n  }\n  return context;\n}\n\nfunction getItems(content: HTMLElement | null): HTMLElement[] {\n  if (!content) return [];\n  return Array.from(content.querySelectorAll<HTMLElement>(ITEM_SELECTOR));\n}\n\nexport function focusInitialItem(content: HTMLElement | null, initialFocus: InitialFocus): void {\n  const items = getItems(content);\n  if (items.length === 0) return;\n  if (initialFocus === \"last\") {\n    items[items.length - 1].focus();\n    return;\n  }\n  // Ouvrir sur la valeur courante évite d'avoir à parcourir la liste pour\n  // retrouver où l'on en est. À défaut d'item coché, le premier.\n  const checked = items.find((item) => item.getAttribute(\"aria-checked\") === \"true\");\n  (initialFocus === \"checked\" ? (checked ?? items[0]) : items[0]).focus();\n}\n\nexport interface PlayerMenuProps {\n  children: ReactNode;\n  className?: string;\n}\n\n/**\n * Le cadre et l'état ouvert. `relative`, parce que c'est lui qui sert de\n * référence au popup `absolute`.\n */\nexport function PlayerMenu({ children, className }: PlayerMenuProps) {\n  const [open, setOpen] = useState(false);\n  const id = useId();\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const initialFocusRef = useRef<InitialFocus>(\"checked\");\n\n  // Un menu ouvert ne peut pas voir sa barre s'effacer sous lui.\n  useHoldControlsVisible(open);\n\n  const openMenu = useCallback((initialFocus: InitialFocus) => {\n    initialFocusRef.current = initialFocus;\n    setOpen(true);\n  }, []);\n\n  const closeMenu = useCallback((restoreFocus: boolean) => {\n    const content = contentRef.current;\n    // Le focus ne revient au déclencheur que s'il était **dans** le popup.\n    // Sinon on le volerait à l'endroit où l'utilisateur vient de cliquer.\n    const shouldRestore =\n      restoreFocus && content !== null && content.contains(document.activeElement);\n    setOpen(false);\n    // Avant que React ne démonte le popup : déplacer le focus après coup\n    // n'aurait plus de sens, le navigateur l'aurait déjà renvoyé au `body`.\n    if (shouldRestore) triggerRef.current?.focus();\n  }, []);\n\n  useEffect(() => {\n    if (!open) return;\n\n    const handlePointerDown = (event: PointerEvent) => {\n      const path = event.composedPath();\n      const content = contentRef.current;\n      const trigger = triggerRef.current;\n      if (content && path.includes(content)) return;\n      // Le déclencheur ferme par son propre `onClick` : fermer ici aussi ferait\n      // basculer deux fois, et le menu se rouvrirait dans la foulée.\n      if (trigger && path.includes(trigger)) return;\n      closeMenu(false);\n    };\n\n    // `pointerdown` et non `click` : un glisser commencé ailleurs — sur le\n    // scrubber, typiquement — doit fermer tout de suite, pas au relâchement.\n    // En capture, pour qu'aucun `stopPropagation()` en chemin ne nous empêche\n    // de le voir. `composedPath()` plutôt que `contains(event.target)` : il\n    // traverse les Shadow DOM, et le lecteur peut être embarqué dans l'un.\n    document.addEventListener(\"pointerdown\", handlePointerDown, true);\n    return () => document.removeEventListener(\"pointerdown\", handlePointerDown, true);\n  }, [closeMenu, open]);\n\n  const context = useMemo<PlayerMenuContextValue>(\n    () => ({\n      open,\n      triggerId: `${id}-trigger`,\n      contentId: `${id}-content`,\n      triggerRef,\n      contentRef,\n      initialFocusRef,\n      openMenu,\n      closeMenu,\n    }),\n    [closeMenu, id, open, openMenu],\n  );\n\n  return (\n    <PlayerMenuContext.Provider value={context}>\n      <div data-slot=\"player-menu\" className={cn(\"relative\", className)}>\n        {children}\n      </div>\n    </PlayerMenuContext.Provider>\n  );\n}\n\n/**\n * Le type de props est **le nôtre**, étroit, et surtout pas\n * `ComponentProps<typeof Button>` : le `Button` du consommateur n'a pas la même\n * API selon qu'il vient de `radix` ou de `base`. Exposer la sienne reviendrait\n * à exposer cette différence. On encapsule, et on ne laisse passer que ce dont\n * un déclencheur de menu a besoin.\n */\nexport interface PlayerMenuTriggerProps {\n  children: ReactNode;\n  className?: string;\n  /** Anglais : c'est la langue des libellés du lecteur. */\n  \"aria-label\"?: string;\n  disabled?: boolean;\n}\n\nexport function PlayerMenuTrigger({\n  children,\n  className,\n  disabled,\n  \"aria-label\": ariaLabel,\n}: PlayerMenuTriggerProps) {\n  const { open, triggerId, contentId, triggerRef, contentRef, openMenu, closeMenu } = useMenu();\n\n  const handleKeyDown = (event: ReactKeyboardEvent<HTMLElement>) => {\n    switch (event.key) {\n      case \"Enter\":\n      case \" \":\n        // `preventDefault` coupe l'activation native du bouton : sans lui,\n        // `Entrée` déclencherait aussi un `click`, donc une seconde bascule.\n        event.preventDefault();\n        event.stopPropagation();\n        if (open) closeMenu(false);\n        else openMenu(\"checked\");\n        break;\n      case \"ArrowDown\":\n      case \"ArrowUp\": {\n        event.preventDefault();\n        event.stopPropagation();\n        const initialFocus = event.key === \"ArrowUp\" ? \"last\" : \"checked\";\n        // Déjà ouvert — le focus n'était pas descendu, faute d'item à\n        // l'ouverture, ou il est remonté ici : `openMenu` ne remonterait rien,\n        // l'état ne changeant pas. On entre donc directement.\n        if (open) focusInitialItem(contentRef.current, initialFocus);\n        else openMenu(initialFocus);\n        break;\n      }\n      case \"Escape\":\n        // Un menu peut être ouvert sans contenir d'item focalisable : le focus\n        // est resté ici, et c'est donc ici qu'il faut pouvoir refermer.\n        if (!open) break;\n        event.preventDefault();\n        event.stopPropagation();\n        closeMenu(false);\n        break;\n      default:\n        break;\n    }\n  };\n\n  return (\n    <Button\n      ref={triggerRef}\n      id={triggerId}\n      type=\"button\"\n      variant=\"ghost\"\n      // `sm` et non `icon-sm` : aucun déclencheur ne porte plus de texte, mais\n      // la largeur reste libre pour qu'un contenu à venir — une icône suivie\n      // d'un badge, par exemple — ne soit pas rogné. Même hauteur que les\n      // autres boutons de la barre.\n      size=\"sm\"\n      disabled={disabled}\n      aria-label={ariaLabel}\n      aria-haspopup=\"menu\"\n      aria-expanded={open}\n      // Ne pointer que vers un nœud qui existe : le popup n'est monté\n      // qu'ouvert.\n      aria-controls={open ? contentId : undefined}\n      // `aria-expanded:bg-muted` est déjà dans la variante `ghost` des deux\n      // styles : l'état ouvert est stylé sans qu'on ait rien à ajouter.\n      className={className}\n      onClick={() => (open ? closeMenu(true) : openMenu(\"checked\"))}\n      onKeyDown={handleKeyDown}\n    >\n      {children}\n    </Button>\n  );\n}\n\nexport interface PlayerMenuContentProps {\n  children: ReactNode;\n  className?: string;\n}\n\n/**\n * Le popup. Monté seulement ouvert — c'est ce montage qui sert de signal\n * d'ouverture au reste du comportement, d'où la séparation en deux composants.\n */\nexport function PlayerMenuContent(props: PlayerMenuContentProps) {\n  const { open } = useMenu();\n  if (!open) return null;\n  return <PlayerMenuPopup {...props} />;\n}\n\nfunction PlayerMenuPopup({ children, className }: PlayerMenuContentProps) {\n  const { triggerId, contentId, triggerRef, contentRef, initialFocusRef, closeMenu } = useMenu();\n  // La chaîne de saisie rapide et son minuteur vivent dans une ref : les\n  // changer ne doit rien re-rendre, seul le focus bouge.\n  const typeaheadRef = useRef({ query: \"\", timer: 0 });\n\n  // Avant la première peinture : le popup ne doit jamais apparaître à sa\n  // hauteur naturelle puis se raccourcir. Le conteneur du lecteur est\n  // `overflow-hidden`, et un lecteur étroit est aussi un lecteur bas : le popup,\n  // ancré dans la barre, ne connaît pas cette hauteur en CSS. Seule mesure faite\n  // en JavaScript ; elle passe par une variable CSS posée à la main et non par\n  // un `style` JSX, que le lint interdit et que React réécrirait au rendu.\n  useLayoutEffect(() => {\n    const content = contentRef.current;\n    const trigger = triggerRef.current;\n    const player = trigger?.closest('[data-slot=\"video-player\"]');\n    if (!content || !trigger || !player) return;\n    const room =\n      trigger.getBoundingClientRect().top - player.getBoundingClientRect().top - POPUP_MARGIN;\n    content.style.setProperty(\"--player-menu-max-h\", `${Math.max(room, 0)}px`);\n  }, [contentRef, triggerRef]);\n\n  useEffect(() => {\n    // Au montage, donc à l'ouverture. Le focus DOM entre réellement dans le\n    // menu : c'est ce qui permet de reprendre le `focus:bg-accent` de l'amont\n    // sans l'adapter, et c'est aussi la bonne implémentation accessible.\n    focusInitialItem(contentRef.current, initialFocusRef.current);\n  }, [contentRef, initialFocusRef]);\n\n  useEffect(() => {\n    const typeahead = typeaheadRef.current;\n    return () => window.clearTimeout(typeahead.timer);\n  }, []);\n\n  const runTypeahead = (character: string) => {\n    const typeahead = typeaheadRef.current;\n    window.clearTimeout(typeahead.timer);\n    typeahead.query += character.toLowerCase();\n    typeahead.timer = window.setTimeout(() => {\n      typeahead.query = \"\";\n    }, TYPEAHEAD_RESET_MS);\n\n    const match = getItems(contentRef.current).find((item) =>\n      (item.textContent ?? \"\").trim().toLowerCase().startsWith(typeahead.query),\n    );\n    // Sans correspondance on ne bouge pas le focus, et on garde la chaîne :\n    // l'utilisateur est peut-être au milieu d'un mot.\n    match?.focus();\n  };\n\n  // `stopPropagation()` sur chaque touche traitée. Sans ça, la couche de\n  // raccourcis clavier du lecteur verra les flèches pendant la navigation dans\n  // le menu et changera le volume sous le nez de l'utilisateur.\n  const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    const items = getItems(contentRef.current);\n    const active = document.activeElement;\n    const current = items.findIndex((item) => item === active);\n\n    switch (event.key) {\n      case \"ArrowDown\":\n      case \"ArrowUp\": {\n        event.preventDefault();\n        event.stopPropagation();\n        if (items.length === 0) return;\n        const step = event.key === \"ArrowDown\" ? 1 : -1;\n        // Bouclage : du dernier, `↓` revient au premier. Focus hors liste —\n        // il n'y était pas encore — on entre par le bout correspondant.\n        const next =\n          current === -1\n            ? step === 1\n              ? 0\n              : items.length - 1\n            : (current + step + items.length) % items.length;\n        items[next].focus();\n        return;\n      }\n      case \"Home\":\n      case \"End\": {\n        event.preventDefault();\n        event.stopPropagation();\n        if (items.length === 0) return;\n        (event.key === \"Home\" ? items[0] : items[items.length - 1]).focus();\n        return;\n      }\n      case \"Enter\":\n      case \" \": {\n        // Uniformise `Entrée`, qui clique au `keydown`, et `Espace`, qui clique\n        // au `keyup` après avoir fait défiler la page : un seul chemin\n        // d'activation, et la page ne bouge pas.\n        event.preventDefault();\n        event.stopPropagation();\n        items[current]?.click();\n        return;\n      }\n      case \"ArrowLeft\":\n      case \"ArrowRight\": {\n        // Le popup a le focus : `←`/`→` ne doivent pas faire avancer la vidéo.\n        // Un contenu qui s'en sert (les niveaux du menu de réglages) les traite\n        // avant nous, depuis ses propres items.\n        event.preventDefault();\n        event.stopPropagation();\n        return;\n      }\n      case \"Escape\": {\n        event.preventDefault();\n        event.stopPropagation();\n        closeMenu(true);\n        return;\n      }\n      case \"Tab\": {\n        event.stopPropagation();\n        // Pas de `preventDefault` : sortir de la barre au clavier doit rester\n        // possible, et c'est la tabulation qui le permet. Mais le focus est sur\n        // un item que React s'apprête à démonter ; s'il disparaissait avant que\n        // le navigateur n'applique le déplacement, la tabulation repartirait du\n        // début du document. On ramène donc le focus au déclencheur, qui lui\n        // survit, avant de fermer — il ne s'y arrête pas, il ne fait qu'y\n        // transiter, et le déplacement se calcule depuis un nœud vivant.\n        triggerRef.current?.focus();\n        closeMenu(false);\n        return;\n      }\n      default:\n        break;\n    }\n\n    // Saisie rapide : un caractère imprimable déplace le focus sur le premier\n    // item dont le texte commence par la chaîne accumulée. `Espace` n'arrive\n    // jamais jusqu'ici — il active, au-dessus.\n    if (event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {\n      event.preventDefault();\n      event.stopPropagation();\n      runTypeahead(event.key);\n    }\n  };\n\n  const handleBlur = (event: ReactFocusEvent<HTMLDivElement>) => {\n    const next = event.relatedTarget;\n    // `relatedTarget` nul, c'est un focus qui ne va nulle part : la fenêtre\n    // perd la main, ou Safari dé-focalise à l'appui sur un bouton — auquel cas\n    // fermer ici rendrait la sélection à la souris impossible sur ce\n    // navigateur. Les vrais clics extérieurs sont déjà couverts par le\n    // `pointerdown` du conteneur.\n    if (!next) return;\n    if (contentRef.current?.contains(next)) return;\n    if (triggerRef.current?.contains(next)) return;\n    closeMenu(false);\n  };\n\n  return (\n    <div\n      ref={contentRef}\n      id={contentId}\n      role=\"menu\"\n      aria-labelledby={triggerId}\n      data-slot=\"player-menu-content\"\n      // En amont c'est la primitive qui pose cet attribut ; ici le popup\n      // n'existe qu'ouvert, donc il est toujours là. Il n'est pas décoratif :\n      // c'est lui qui déclenche l'animation d'entrée.\n      data-open=\"\"\n      className={cn(\n        POPUP_CLASSNAME,\n        // Vers le haut et aligné à droite : seule direction possible pour une\n        // barre en bas. La hauteur est bornée à 16 rem, ou à la place qui reste\n        // au-dessus du déclencheur si elle est moindre (variable posée par\n        // l'effet de mise en page ci-dessus) : le conteneur du lecteur est\n        // `overflow-hidden`, un popup plus haut que la vidéo serait coupé.\n        //\n        // `w-max` n'est pas une coquetterie. Un élément `absolute` sans `left`\n        // se dimensionne en « shrink-to-fit », borné par la largeur disponible\n        // dans son bloc conteneur — ici le cadre du menu, large comme son seul\n        // bouton. Sans lui, le popup s'effondre donc sur son **min-content**,\n        // c'est-à-dire sur le mot le plus long de sa liste : « Big Buck Bunny\n        // wakes up » s'écrivait sur cinq lignes. Les menus de vitesse et de\n        // qualité ne le montraient pas, leurs libellés étant si courts que le\n        // `min-w-32` couvrait le défaut. `max-w-64` prend alors le relais du\n        // repli, pour qu'un titre à rallonge ne pousse pas le popup hors de la\n        // vidéo — le pendant horizontal de la borne de hauteur.\n        \"absolute right-0 bottom-full mb-2 max-h-[min(16rem,var(--player-menu-max-h,16rem))] w-max max-w-64\",\n        className,\n      )}\n      onKeyDown={handleKeyDown}\n      onBlur={handleBlur}\n    >\n      {children}\n    </div>\n  );\n}\n\nexport interface PlayerMenuItemProps {\n  /** Appelé au clic ; le menu reste ouvert, c'est à l'appelant de le fermer. */\n  onSelect: () => void;\n  disabled?: boolean;\n  className?: string;\n  children: ReactNode;\n}\n\n/**\n * Un item simple, sans état coché : une ligne qui mène ailleurs, comme celles\n * du menu de réglages. Il ne ferme pas le menu — ouvrir un sous-niveau ne\n * quitte pas le popup.\n */\nexport function PlayerMenuItem({ onSelect, disabled, className, children }: PlayerMenuItemProps) {\n  return (\n    <button\n      type=\"button\"\n      role=\"menuitem\"\n      data-slot=\"player-menu-item\"\n      data-disabled={disabled ? \"\" : undefined}\n      disabled={disabled}\n      tabIndex={-1}\n      className={cn(ITEM_CLASSNAME, className)}\n      onClick={onSelect}\n    >\n      {children}\n    </button>\n  );\n}\n\nexport interface PlayerMenuRadioItemProps {\n  checked: boolean;\n  onSelect: () => void;\n  disabled?: boolean;\n  className?: string;\n  children: ReactNode;\n}\n\nexport function PlayerMenuRadioItem({\n  checked,\n  onSelect,\n  disabled,\n  className,\n  children,\n}: PlayerMenuRadioItemProps) {\n  const { closeMenu } = useMenu();\n\n  return (\n    <button\n      type=\"button\"\n      role=\"menuitemradio\"\n      aria-checked={checked}\n      data-slot=\"player-menu-item\"\n      // `data-disabled` porte le style repris de l'amont et sert de filtre à la\n      // navigation ; `disabled` met réellement le bouton hors d'atteinte.\n      data-disabled={disabled ? \"\" : undefined}\n      disabled={disabled}\n      // Le menu n'a qu'un point d'entrée au clavier, son déclencheur. À\n      // l'intérieur, c'est le menu qui déplace le focus, pas la tabulation.\n      tabIndex={-1}\n      className={cn(ITEM_CLASSNAME, className)}\n      onClick={() => {\n        onSelect();\n        // On ferme et on rend le focus : au clavier, l'utilisateur doit\n        // retrouver le déclencheur, désormais à jour de son choix.\n        closeMenu(true);\n      }}\n    >\n      {children}\n      {checked ? (\n        <span className=\"pointer-events-none absolute right-2 flex items-center justify-center\">\n          <CheckIcon />\n        </span>\n      ) : null}\n    </button>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/video-player/player-menu.tsx"
    },
    {
      "path": "registry/videocn/player-slider.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n} from \"react\";\nimport { cn } from \"cn\";\n\nimport { useHoldControlsVisible } from \"./controls-context\";\n\n/**\n * Le curseur partagé par le scrubber et le volume. Un seul composant pour deux\n * plages : c'est ce qui permet au volume d'avoir enfin un nom accessible, et\n * aux chapitres de la phase 5 de se loger dans la piste sans refonte.\n *\n * **La position ne passe pas par React.** Le scrubber bouge soixante fois par\n * seconde ; une prop qui changerait à ce rythme re-rendrait le curseur autant\n * de fois, exactement ce que les stores du lecteur ont éliminé. Elle est donc\n * portée par la propriété CSS `--player-slider-fraction`, de 0 à 1 et **sans\n * unité** — les segments de chapitres en déduiront leur part jouée en CSS pur —,\n * posée sur la racine et héritée par toutes les couches.\n *\n * **Un seul écrivain : `paint()`.** Il *tire* la vérité au lieu de se la faire\n * pousser — le doigt d'abord, puis la position vive, puis `value` —, si bien\n * que l'ordre d'arrivée de ses déclencheurs n'a aucune importance : un `seeked`\n * tardif pendant un glissement ne peut pas écraser le doigt.\n *\n * **Cette clé ne doit jamais apparaître dans un `style` JSX.** React ne diffe\n * que les clés qu'il gère : tant qu'on ne la lui confie pas, il ne l'écrasera\n * jamais. La lui confier, ce serait deux écrivains pour une propriété.\n *\n * Ce que React rend, en revanche, c'est l'ARIA — sur `value`, une valeur\n * grossière (la seconde entière pour le scrubber). Un lecteur d'écran focalisé\n * sur un curseur qui s'annoncerait à 60 Hz deviendrait inutilisable.\n *\n * Aucune classe de positionnement que le CLI shadcn réécrit pour les projets\n * RTL : ni translation horizontale, ni point d'origine de transformation, ni\n * mise à l'échelle horizontale. Le point d'origine « gauche » y devient un\n * point d'origine « début » qui n'existe pas en Tailwind — la classe disparaît\n * sans bruit —, et la translation gagne une variante `rtl:` qui s'applique même\n * sous un îlot `dir=\"ltr\"`. Tout est posé par `left` et `width`, et la racine\n * porte `dir=\"ltr\"` : une timeline ne se met pas en miroir.\n */\n\n/** Pourquoi la valeur change — le consommateur en déduit l'effet sur la vidéo. */\nexport type SliderChangeReason = \"start\" | \"move\" | \"end\" | \"cancel\" | \"key\";\n\n/**\n * Une position vive, dessinée hors de React. Le scrubber y branche la tête de\n * lecture : elle bouge soixante fois par seconde, et la faire passer par une\n * prop re-rendrait le curseur autant de fois.\n */\nexport interface SliderPosition {\n  subscribe(listener: () => void): () => void;\n  getValue(): number;\n}\n\nexport interface PlayerSliderProps {\n  \"aria-label\": string;\n  /** 0 par défaut. Différent de zéro pour la fenêtre DVR de la phase 4. */\n  min?: number;\n  /** Non fini ou inférieur à `min` : le curseur est inerte. */\n  max: number;\n  /**\n   * La valeur annoncée aux technologies d'assistance. Dessinée aussi, quand\n   * `position` est absent — c'est le cas du volume.\n   */\n  value: number;\n  /** Si présent, c'est lui qui est dessiné, et `value` n'est plus qu'annoncée. */\n  position?: SliderPosition;\n  /** Le pas d'une flèche. */\n  step: number;\n  /** Le pas de `PageUp` / `PageDown`. */\n  pageStep: number;\n  /** Le texte lu à la place du nombre : « 42 seconds of 9 minutes 56 seconds ». */\n  getValueText?: (value: number) => string;\n  disabled?: boolean;\n  onValueChange: (value: number, reason: SliderChangeReason) => void;\n  className?: string;\n  /** La ou les pistes, composées par l'appelant. */\n  children: ReactNode;\n}\n\nconst FRACTION_PROPERTY = \"--player-slider-fraction\";\n\n/**\n * Distance à parcourir avant qu'un appui devienne un glissement. Plus large au\n * doigt, qui tremble à la pose : sans ce seuil, un simple tap mettrait la vidéo\n * en pause le temps d'un geste qui n'en était pas un.\n */\nconst DRAG_THRESHOLD_MOUSE = 3;\nconst DRAG_THRESHOLD_TOUCH = 8;\n\ninterface Range {\n  min: number;\n  max: number;\n  /** `max` fini et strictement supérieur à `min`. */\n  valid: boolean;\n}\n\ninterface Gesture {\n  pointerId: number;\n  /** Mémorisé une fois : une seule lecture de layout par geste. */\n  rect: DOMRect;\n  startX: number;\n  threshold: number;\n  /** Là où l'on revient sur `cancel` : la valeur d'avant l'appui. */\n  startValue: number;\n  /** La valeur sous le doigt, que `paint()` dessine en priorité. */\n  value: number;\n  /** Seuil franchi — une fois pour toutes, revenir en arrière ne le réarme pas. */\n  moved: boolean;\n  /** Retire l'écoute d'`Escape` posée pour ce geste. */\n  release: () => void;\n}\n\n/** Ce que `paint()` et les écouteurs hors React lisent : le dernier rendu. */\ninterface Latest {\n  range: Range;\n  value: number;\n  position: SliderPosition | undefined;\n  onValueChange: (value: number, reason: SliderChangeReason) => void;\n}\n\n/**\n * Les bornes telles qu'on peut s'en servir. Une durée pas encore connue arrive\n * en `NaN`, un direct en `Infinity` : dans les deux cas on s'effondre sur\n * `min`, pour qu'aucun `NaN` ni `Infinity` n'atteigne un attribut ARIA.\n */\nfunction resolveRange(min: number, max: number): Range {\n  const safeMin = Number.isFinite(min) ? min : 0;\n  const valid = Number.isFinite(max) && max > safeMin;\n  return { min: safeMin, max: valid ? max : safeMin, valid };\n}\n\nfunction clamp(value: number, range: Range): number {\n  if (Number.isNaN(value)) return range.min;\n  return Math.min(Math.max(value, range.min), range.max);\n}\n\n/**\n * Retire le bruit de l'arithmétique flottante, sans aligner sur une grille.\n *\n * `0.8 + 0.05` vaut `0.8500000000000001` : la valeur part telle quelle dans\n * `aria-valuenow` et dans `video.volume`, et vingt `↓` d'affilée finissent à\n * `0.4999999999999996`, annoncé « 50 % » quand l'icône du volume a déjà basculé.\n * On n'arrondit pas au pas pour autant : sur le scrubber, `→` depuis 42,9 s doit\n * mener à 47,9 s et non à 48. Dix décimales effacent le bruit et laissent tout le\n * reste — et restent exactes jusqu'à des centaines d'heures de média.\n */\nfunction trimFloat(value: number): number {\n  return Math.round(value * 1e10) / 1e10;\n}\n\nfunction toFraction(value: number, range: Range): number {\n  if (!range.valid) return 0;\n  const fraction = (value - range.min) / (range.max - range.min);\n  if (!Number.isFinite(fraction)) return 0;\n  return Math.min(Math.max(fraction, 0), 1);\n}\n\nfunction valueAt(clientX: number, rect: DOMRect, range: Range): number {\n  const fraction = rect.width > 0 ? (clientX - rect.left) / rect.width : 0;\n  return clamp(range.min + fraction * (range.max - range.min), range);\n}\n\nexport function PlayerSlider({\n  \"aria-label\": ariaLabel,\n  min = 0,\n  max,\n  value,\n  position,\n  step,\n  pageStep,\n  getValueText,\n  disabled = false,\n  onValueChange,\n  className,\n  children,\n}: PlayerSliderProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const gestureRef = useRef<Gesture | null>(null);\n  /** La dernière fraction écrite : on n'écrit pas deux fois la même. */\n  const paintedRef = useRef<number | null>(null);\n  const [dragging, setDragging] = useState(false);\n\n  const range = resolveRange(min, max);\n  const inert = disabled || !range.valid;\n  const announced = clamp(value, range);\n\n  const latestRef = useRef<Latest>({ range, value, position, onValueChange });\n\n  // Tant qu'on tient un curseur, la barre ne se masque pas sous le doigt.\n  useHoldControlsVisible(dragging);\n\n  const paint = useCallback(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const latest = latestRef.current;\n    // Priorité fixe : le doigt, puis la position vive, puis la valeur.\n    const shown =\n      gestureRef.current?.value ??\n      (latest.position ? latest.position.getValue() : latest.value);\n    const fraction = toFraction(shown, latest.range);\n    if (fraction === paintedRef.current) return;\n    paintedRef.current = fraction;\n    root.style.setProperty(FRACTION_PROPERTY, String(fraction));\n  }, []);\n\n  // Déclencheur n° 2 : après chaque rendu, sans tableau de dépendances. C'est\n  // le chemin de `value`, `min` et `max` — donc du volume, et d'une durée qui\n  // arrive ou qui change. En layout effect, pour que la position soit juste\n  // dans la frame même où le rendu est peint.\n  useLayoutEffect(() => {\n    latestRef.current = { range, value, position, onValueChange };\n    paint();\n  });\n\n  // Déclencheur n° 1 : la position vive. Le curseur n'a pas de boucle à lui —\n  // c'est la boucle `requestAnimationFrame` du store qui le cadence pendant la\n  // lecture, et ses événements hors lecture.\n  useEffect(() => {\n    if (!position) return;\n    return position.subscribe(paint);\n  }, [paint, position]);\n\n  const finish = useCallback(\n    (reason: \"end\" | \"cancel\") => {\n      const gesture = gestureRef.current;\n      if (!gesture) return;\n      // Remis à `null` d'abord : c'est ce qui rend la main à la position vive\n      // dans `paint()`, et ce qui neutralise le `lostpointercapture` que la\n      // libération ci-dessous va déclencher.\n      gestureRef.current = null;\n      gesture.release();\n      const root = rootRef.current;\n      if (root?.hasPointerCapture(gesture.pointerId)) {\n        root.releasePointerCapture(gesture.pointerId);\n      }\n      setDragging(false);\n      latestRef.current.onValueChange(\n        reason === \"cancel\" ? gesture.startValue : gesture.value,\n        reason,\n      );\n      // Après l'effet et non avant : la recherche finale a déjà repositionné\n      // l'élément, donc la position tirée ici est la nouvelle, sans retour en\n      // arrière d'une frame.\n      paint();\n    },\n    [paint],\n  );\n\n  // Un démontage en plein geste — l'option retirée, le lecteur démonté — ne\n  // doit laisser ni écouteur sur `window` ni aperçu figé dans le store : on\n  // l'annule comme un `Escape`.\n  useEffect(() => () => finish(\"cancel\"), [finish]);\n\n  const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {\n    // Jamais de `stopPropagation` ni de `preventDefault` ici : les menus\n    // ferment sur un `pointerdown` du document, la visibilité de la\n    // barre l'écoute sur le conteneur, et c'est le comportement natif qui pose\n    // le focus sur la racine.\n    if (inert || gestureRef.current) return;\n    if (event.button !== 0 || !event.isPrimary) return;\n\n    const root = event.currentTarget;\n    root.setPointerCapture(event.pointerId);\n    const rect = root.getBoundingClientRect();\n    const startValue = clamp(position ? position.getValue() : value, range);\n    const pointerValue = valueAt(event.clientX, rect, range);\n\n    // `Escape` en capture sur `window` : le focus peut être n'importe où\n    // pendant un glissement, et aucun `stopPropagation` en chemin ne doit\n    // empêcher d'annuler.\n    const handleEscape = (keyEvent: KeyboardEvent) => {\n      if (keyEvent.key !== \"Escape\") return;\n      keyEvent.preventDefault();\n      keyEvent.stopPropagation();\n      finish(\"cancel\");\n    };\n    window.addEventListener(\"keydown\", handleEscape, true);\n\n    gestureRef.current = {\n      pointerId: event.pointerId,\n      rect,\n      startX: event.clientX,\n      threshold: event.pointerType === \"touch\" ? DRAG_THRESHOLD_TOUCH : DRAG_THRESHOLD_MOUSE,\n      startValue,\n      value: pointerValue,\n      moved: false,\n      release: () => window.removeEventListener(\"keydown\", handleEscape, true),\n    };\n    setDragging(true);\n    paint();\n    onValueChange(pointerValue, \"start\");\n  };\n\n  const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const gesture = gestureRef.current;\n    // Hors geste, rien pour l'instant. C'est ici que les miniatures écriront\n    // plus tard leur variable de survol.\n    if (!gesture || event.pointerId !== gesture.pointerId) return;\n\n    // Seule la distance horizontale compte : une dérive verticale ne change pas\n    // la valeur, et ne doit pas suffire à mettre la vidéo en pause.\n    if (!gesture.moved && Math.abs(event.clientX - gesture.startX) < gesture.threshold) return;\n\n    const next = valueAt(event.clientX, gesture.rect, range);\n    // Au-delà d'un bord, la valeur reste bornée : inutile de réémettre la même.\n    if (gesture.moved && next === gesture.value) return;\n    gesture.moved = true;\n    gesture.value = next;\n    paint();\n    onValueChange(next, \"move\");\n  };\n\n  const handlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (gestureRef.current?.pointerId !== event.pointerId) return;\n    finish(\"end\");\n  };\n\n  const handlePointerCancel = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (gestureRef.current?.pointerId !== event.pointerId) return;\n    finish(\"cancel\");\n  };\n\n  // La capture perdue sans `pointerup` — un autre élément l'a prise, la\n  // fenêtre a perdu la main : le geste s'arrête là où il en était.\n  const handleLostPointerCapture = () => finish(\"end\");\n\n  const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    // `Cmd+←`, c'est « Précédent » : les combinaisons appartiennent au\n    // navigateur et au système, on les laisse passer.\n    if (event.altKey || event.ctrlKey || event.metaKey) return;\n    if (inert) return;\n\n    // La base est la position précise, jamais la seconde arrondie annoncée :\n    // `→` depuis 42,9 s doit mener à 47,9 s, pas à 47 s.\n    const base = clamp(position ? position.getValue() : value, range);\n    let next: number;\n    switch (event.key) {\n      case \"ArrowLeft\":\n      case \"ArrowDown\":\n        next = base - step;\n        break;\n      case \"ArrowRight\":\n      case \"ArrowUp\":\n        next = base + step;\n        break;\n      case \"PageDown\":\n        next = base - pageStep;\n        break;\n      case \"PageUp\":\n        next = base + pageStep;\n        break;\n      case \"Home\":\n        next = range.min;\n        break;\n      case \"End\":\n        next = range.max;\n        break;\n      default:\n        // `Espace` compris : il revient à la keymap du lecteur.\n        return;\n    }\n\n    // `stopPropagation` en plus du `preventDefault` : la keymap du lecteur\n    // donne aussi `←`/`→` et `↑`/`↓` au lecteur entier, et sans ça une flèche\n    // sur le scrubber agirait deux fois.\n    event.preventDefault();\n    event.stopPropagation();\n    // Pendant un glissement, la touche est avalée sans effet : la vidéo suit\n    // le doigt, et une flèche qui la déplacerait en parallèle se ferait\n    // écraser au relâchement.\n    if (gestureRef.current || !Number.isFinite(next)) return;\n    onValueChange(clamp(trimFloat(next), range), \"key\");\n  };\n\n  return (\n    <div\n      ref={rootRef}\n      // Le rôle est sur la racine et non sur le thumb : toute la hauteur est\n      // cliquable, un clic sur la piste y pose le focus, et le thumb reste\n      // libre de grandir ou de disparaître.\n      role=\"slider\"\n      tabIndex={inert ? -1 : 0}\n      aria-label={ariaLabel}\n      aria-valuemin={range.min}\n      aria-valuemax={range.max}\n      aria-valuenow={announced}\n      aria-valuetext={getValueText?.(announced)}\n      aria-disabled={inert || undefined}\n      data-slot=\"player-slider\"\n      data-dragging={dragging ? \"\" : undefined}\n      dir=\"ltr\"\n      // Jamais `overflow-hidden` ici — seule la piste l'est. Le thumb déborde\n      // aux extrémités, et la heatmap de la phase 5 se posera au-dessus.\n      // `h-6` : 24 px de cible minimum (WCAG 2.5.8), quelle que soit la piste.\n      className={cn(\n        \"group/slider relative flex h-6 w-full cursor-pointer touch-none items-center outline-none select-none aria-disabled:cursor-default\",\n        className,\n      )}\n      onPointerDown={handlePointerDown}\n      onPointerMove={handlePointerMove}\n      onPointerUp={handlePointerUp}\n      onPointerCancel={handlePointerCancel}\n      onLostPointerCapture={handleLostPointerCapture}\n      onKeyDown={handleKeyDown}\n    >\n      {children}\n      {/*\n        Un conteneur de largeur nulle posé sur la position, qui centre la\n        pastille en flex : un enfant plus large qu'un parent `justify-center`\n        déborde à parts égales des deux côtés. Aucun `translate`, donc rien que\n        la conversion RTL puisse réécrire.\n      */}\n      <div\n        data-slot=\"player-slider-thumb\"\n        className=\"pointer-events-none absolute inset-y-0 left-[calc(var(--player-slider-fraction,0)*100%)] flex w-0 items-center justify-center\"\n      >\n        {/*\n          Aucune transition sur la position — le thumb traînerait derrière le\n          doigt —, seulement sur la taille. Visible au survol (Tailwind limite\n          `hover` aux écrans qui survolent vraiment), au focus clavier, pendant\n          un geste, et en permanence sur écran tactile, où rien ne survole.\n        */}\n        <div\n          // `rounded-lg` et non `rounded-full` : à quatorze pixels, le rayon\n          // de l'hôte est écrêté à la moitié et donne le même rond. Un thème\n          // anguleux, lui, obtient une poignée carrée — comme la sienne.\n          className=\"size-0 shrink-0 rounded-lg bg-primary ring-ring/50 transition-[width,height] duration-150 motion-reduce:transition-none group-hover/slider:size-3.5 group-focus-visible/slider:size-3.5 group-focus-visible/slider:ring-3 group-data-dragging/slider:size-3.5 pointer-coarse:size-3.5\"\n        />\n      </div>\n    </div>\n  );\n}\n\nexport interface PlayerSliderLayerProps {\n  className?: string;\n  children?: ReactNode;\n}\n\n/**\n * Le rail : la piste de fond, qui découpe ses couches aux coins arrondis. Elle\n * s'épaissit au survol et pendant un geste, depuis son centre — la racine la\n * centre verticalement.\n */\nexport function PlayerSliderTrack({ className, children }: PlayerSliderLayerProps) {\n  return (\n    <div\n      data-slot=\"player-slider-track\"\n      className={cn(\n        // Le rayon de l'hôte, écrêté par la hauteur de la piste : identique à\n        // une pilule partout, sauf sur un thème à angles vifs.\n        \"relative h-1 w-full overflow-hidden rounded-lg bg-foreground/20 transition-[height] duration-150 motion-reduce:transition-none group-hover/slider:h-1.5 group-data-dragging/slider:h-1.5\",\n        className,\n      )}\n    >\n      {children}\n    </div>\n  );\n}\n\n/** La partie jouée, de 0 jusqu'à la position courante. */\nexport function PlayerSliderRange({ className, children }: PlayerSliderLayerProps) {\n  return (\n    <div\n      data-slot=\"player-slider-range\"\n      className={cn(\n        \"absolute inset-y-0 left-0 w-[calc(var(--player-slider-fraction,0)*100%)] bg-primary\",\n        className,\n      )}\n    >\n      {children}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/video-player/player-slider.tsx"
    },
    {
      "path": "registry/videocn/use-scrub.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef } from \"react\";\n\nimport { usePlayerActions, usePlayerStore, usePlayheadStore } from \"./player-context\";\nimport type { SliderChangeReason } from \"./player-slider\";\n\n/**\n * Le protocole glissement → vidéo, sur le modèle de YouTube : la lecture se met\n * en pause au premier vrai déplacement, la vidéo cherche en continu à mesure\n * que le doigt bouge, et la lecture reprend au relâchement si elle tournait.\n *\n * Le curseur ne sait rien de la vidéo : il dit seulement *pourquoi* sa valeur\n * change. C'est ici qu'on en déduit l'effet, pour que le volume puisse se\n * brancher sur le même curseur sans hériter de ce protocole.\n */\n\n/**\n * Une recherche au plus toutes les 150 ms pendant un glissement. Chaque\n * écriture de `currentTime` interrompt le décodage en cours et, sur un fichier\n * progressif, relance une requête de plage : à la cadence d'un `pointermove`,\n * aucune recherche n'aboutirait et l'image resterait figée tout le geste. À ce\n * rythme, chaque recherche a le temps de peindre une image.\n */\nconst SEEK_INTERVAL = 150;\n\n/**\n * L'état d'un geste. Il vit dans une ref et non dans un état : il change à\n * chaque `move`, et rien ne doit se re-rendre pour autant.\n */\ninterface ScrubGesture {\n  /** La lecture tournait au moment du `start`. */\n  wasPlaying: boolean;\n  /** C'est nous qui avons mis en pause, donc c'est à nous de relancer. */\n  pausedByUs: boolean;\n  /** Au moins un `move` depuis le `start` — un simple clic n'en a aucun. */\n  moved: boolean;\n  /** La dernière valeur envoyée à la vidéo, `NaN` tant qu'il n'y en a pas. */\n  lastSeek: number;\n  /** Quand elle a été envoyée, pour le limiteur. */\n  lastSeekAt: number;\n  /** La dernière valeur reçue : c'est elle que la recherche de queue enverra. */\n  latest: number;\n  timer: ReturnType<typeof setTimeout> | null;\n}\n\nfunction idleGesture(): ScrubGesture {\n  return {\n    wasPlaying: false,\n    pausedByUs: false,\n    moved: false,\n    lastSeek: Number.NaN,\n    lastSeekAt: Number.NEGATIVE_INFINITY,\n    latest: Number.NaN,\n    timer: null,\n  };\n}\n\nfunction resetGesture(gesture: ScrubGesture): void {\n  if (gesture.timer !== null) clearTimeout(gesture.timer);\n  // L'objet est muté et non remplacé : le nettoyage du démontage en garde une\n  // référence, et doit pouvoir y trouver le minuteur en cours.\n  Object.assign(gesture, idleGesture());\n}\n\n/**\n * Renvoie un gestionnaire de référence stable, à passer tel quel au\n * `onValueChange` du curseur. Il lit l'état et la tête de lecture **dans\n * l'événement**, sans s'y abonner : le scrubber ne doit pas se re-rendre parce\n * que la vidéo a avancé.\n */\nexport function useScrub(): (value: number, reason: SliderChangeReason) => void {\n  const store = usePlayerStore();\n  const playhead = usePlayheadStore();\n  const { seek, play, pause } = usePlayerActions();\n\n  const gestureRef = useRef<ScrubGesture>(idleGesture());\n\n  useEffect(() => {\n    const gesture = gestureRef.current;\n    return () => {\n      if (gesture.timer !== null) clearTimeout(gesture.timer);\n      gesture.timer = null;\n    };\n  }, []);\n\n  return useCallback(\n    (value: number, reason: SliderChangeReason) => {\n      const gesture = gestureRef.current;\n\n      const seekNow = (time: number) => {\n        seek(time);\n        gesture.lastSeek = time;\n        gesture.lastSeekAt = performance.now();\n      };\n\n      const flush = () => {\n        gesture.timer = null;\n        if (gesture.latest !== gesture.lastSeek) seekNow(gesture.latest);\n      };\n\n      /**\n       * Limiteur en tête et en queue. En tête, pour que la vidéo réagisse dès\n       * le premier mouvement après une pause du doigt ; en queue, avec la\n       * dernière valeur reçue, pour que l'image rattrape le doigt quand il\n       * s'immobilise sans lâcher. La recherche du `start` compte dans la\n       * fenêtre : un clic suivi d'un déplacement n'en fait pas deux d'affilée.\n       */\n      const throttledSeek = (time: number) => {\n        gesture.latest = time;\n        if (gesture.timer !== null) return;\n        const elapsed = performance.now() - gesture.lastSeekAt;\n        if (elapsed >= SEEK_INTERVAL) seekNow(time);\n        else gesture.timer = setTimeout(flush, SEEK_INTERVAL - elapsed);\n      };\n\n      /** Fin du geste, qu'il soit relâché ou annulé. */\n      const finish = (time: number, alwaysSeek: boolean) => {\n        const { pausedByUs, lastSeek } = gesture;\n        // Annule la recherche de queue en attente : la recherche exacte qui\n        // suit la remplace.\n        resetGesture(gesture);\n        // Un simple clic a déjà cherché cette valeur exacte au `start` : pas de\n        // seconde recherche. La recherche passe avant le retrait de l'aperçu —\n        // le store relit l'élément au retrait et le trouve déjà à sa place,\n        // donc la tête ne revient pas en arrière le temps d'une frame.\n        if (alwaysSeek || time !== lastSeek) seek(time);\n        playhead.scrub(null);\n        // Relâché au bout de la vidéo, `play()` repartirait de zéro : la\n        // lecture reste arrêtée, comme si elle s'était terminée d'elle-même.\n        // `play()` rejette si le navigateur refuse — onglet en arrière-plan,\n        // politique d'autoplay — et ce n'est pas une erreur du lecteur.\n        if (pausedByUs && time < store.getSnapshot().duration) play().catch(() => {});\n      };\n\n      switch (reason) {\n        case \"start\": {\n          resetGesture(gesture);\n          gesture.wasPlaying = !store.getSnapshot().paused;\n          playhead.scrub(value);\n          seekNow(value);\n          gesture.latest = value;\n          return;\n        }\n        case \"move\": {\n          // La pause attend le premier vrai déplacement : un simple clic cherche\n          // sans interrompre la lecture, et le bouton lecture ne clignote pas\n          // au geste le plus fréquent.\n          if (!gesture.moved) {\n            gesture.moved = true;\n            if (gesture.wasPlaying) {\n              pause();\n              gesture.pausedByUs = true;\n            }\n          }\n          playhead.scrub(value);\n          throttledSeek(value);\n          return;\n        }\n        case \"end\": {\n          finish(value, false);\n          return;\n        }\n        case \"cancel\": {\n          // La valeur est celle d'avant le geste. On a déjà cherché dès le\n          // `start`, donc il faut toujours y retourner, même sans déplacement.\n          finish(value, true);\n          return;\n        }\n        case \"key\": {\n          // Un pas de flèche est une recherche ponctuelle : ni aperçu, ni pause.\n          seek(value);\n          return;\n        }\n      }\n    },\n    [pause, play, playhead, seek, store],\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/video-player/use-scrub.ts"
    },
    {
      "path": "registry/videocn/player-scrubber.tsx",
      "content": "\"use client\";\n\nimport { memo, useEffect, useMemo, useRef } from \"react\";\n\nimport { ChapterSegments } from \"./chapter-segments\";\nimport { findChapterIndex } from \"./chapters\";\nimport { useChapters } from \"./chapters-context\";\nimport { useControlsOptions } from \"./controls-context\";\nimport { DEFAULT_SEEK_STEP, LIVE_EDGE_TOLERANCE } from \"./controls-options\";\nimport { formatSpokenTime } from \"./format-time\";\nimport { usePlayerValue, usePlayheadStore, usePlayheadValue } from \"./player-context\";\nimport { PlayerSlider, PlayerSliderRange, type SliderPosition } from \"./player-slider\";\nimport { selectIsLive } from \"./player-state-store\";\nimport { displayedTime, type PlayheadSnapshot } from \"./playhead-store\";\nimport { useScrub } from \"./use-scrub\";\n\n/**\n * Le scrubber : le curseur partagé, branché sur la tête de lecture et sur\n * `useScrub`, avec l'aperçu du buffer. Aucune prop, comme les autres contrôles.\n *\n * Il ne se re-rend qu'une fois par seconde de média, pour l'ARIA. Tout ce qui\n * bouge plus vite — la position, le buffer — est écrit hors de React, en\n * propriétés CSS, chacune par un seul écrivain : le curseur pour sa fraction,\n * ce composant pour les bornes du buffer.\n *\n * En direct, la plage n'est plus `0 → durée` mais la fenêtre encore diffusée,\n * qui glisse en permanence. Tout ce que le curseur reçoit — bornes, pas de\n * page, aperçu du buffer — est exprimé dans cette plage ; en vidéo à la\n * demande, rien ne change.\n *\n * Les couches qu'il compose ne sont pas rendues directement dans une piste mais\n * confiées à `ChapterSegments`, qui **est** la piste et les réplique dans chaque\n * chapitre. Le partage est net : le scrubber décide de ce qui est dessiné, le\n * découpage décide d'où. Sans chapitres, le découpage rend un segment unique et\n * le résultat est celui d'avant, au pixel près.\n */\n\nconst BUFFER_START_PROPERTY = \"--player-buffer-start\";\nconst BUFFER_END_PROPERTY = \"--player-buffer-end\";\n\n/**\n * En dessous de cette fenêtre, le curseur reste inerte en direct.\n *\n * Un direct sans DVR expose quand même quelques segments — une playlist HLS en\n * garde trois, soit une vingtaine de secondes —, mais cette fenêtre n'est pas\n * un historique : c'est la réserve de lecture, elle glisse aussi vite qu'on la\n * parcourt, et toute la largeur de la barre n'y vaudrait qu'une poignée de\n * secondes. Trente secondes séparent les flux qu'on peut réellement remonter\n * de ceux où chercher ne ferait que provoquer un recalage.\n */\nconst MIN_LIVE_SEEKABLE_WINDOW = 30;\n\n/**\n * Les bornes de la fenêtre cherchable, arrondies **vers l'intérieur** : elles\n * ne changent alors qu'une fois par seconde, et le scrubber garde sa propriété\n * de ne se re-rendre qu'à ce rythme — les passer brutes le re-rendrait soixante\n * fois par seconde. Vers l'intérieur, pour qu'on ne puisse jamais viser un\n * instant que le flux n'a pas.\n */\nfunction selectSeekableStart(snapshot: PlayheadSnapshot): number {\n  return Math.ceil(snapshot.seekableStart);\n}\n\nfunction selectSeekableEnd(snapshot: PlayheadSnapshot): number {\n  return Math.floor(snapshot.seekableEnd);\n}\n\n/** Un instant rapporté à la plage du curseur. Plage vide : zéro, rien de travers. */\nfunction toFraction(time: number, min: number, max: number): number {\n  const span = max - min;\n  if (!Number.isFinite(span) || span <= 0) return 0;\n  const fraction = (time - min) / span;\n  if (!Number.isFinite(fraction)) return 0;\n  return Math.min(Math.max(fraction, 0), 1);\n}\n\nexport const PlayerScrubber = memo(function PlayerScrubber() {\n  const { scrubber } = useControlsOptions();\n  // La seconde entière et non le temps exact : un rendu par seconde de média,\n  // pas soixante. Elle ne sert qu'à l'annonce ; le dessin passe par `position`.\n  const second = usePlayheadValue((snapshot) => Math.floor(displayedTime(snapshot)));\n  const duration = usePlayerValue((state) => state.duration);\n  const isLive = usePlayerValue(selectIsLive);\n  const seekableStart = usePlayheadValue(selectSeekableStart);\n  const seekableEnd = usePlayheadValue(selectSeekableEnd);\n  // Une lecture de contexte, pas un abonnement : la liste ne change qu'avec la\n  // durée, donc le scrubber garde son rendu par seconde de média.\n  const chapters = useChapters();\n  const playhead = usePlayheadStore();\n  const onValueChange = useScrub();\n  const wrapperRef = useRef<HTMLDivElement>(null);\n\n  // `displayedTime` et non `currentTime` : pendant un glissement, le store\n  // porte la position du doigt, et tout ce qui la lit la suit.\n  const position = useMemo<SliderPosition>(\n    () => ({\n      subscribe: playhead.subscribe,\n      getValue: () => displayedTime(playhead.getSnapshot()),\n    }),\n    [playhead],\n  );\n\n  const enabled = scrubber.enabled;\n  const finite = Number.isFinite(duration) && duration > 0;\n\n  // En direct, la fenêtre encore diffusée ; sinon la vidéo entière, telle\n  // qu'elle a toujours été. `seekable` n'est pas consulté en vidéo à la\n  // demande : il s'y confond avec la durée, et une fenêtre qui n'arriverait\n  // qu'après les métadonnées ferait sauter les bornes pour rien.\n  const min = isLive ? seekableStart : 0;\n  const max = isLive ? seekableEnd : duration;\n  const span = max - min;\n\n  // Le buffer, par abonnement et jamais par un rendu : la plage chargée grossit\n  // au fil des `progress`, et chaque pas re-rendrait le scrubber pour rien.\n  // Écrit sur l'enveloppe et non sur la racine du curseur, qui a déjà son\n  // écrivain : les variables descendent par cascade, et chaque élément garde au\n  // plus un écrivain impératif.\n  useEffect(() => {\n    if (!enabled) return;\n    const wrapper = wrapperRef.current;\n    if (!wrapper) return;\n\n    // `NaN` au départ : la première comparaison échoue toujours, donc la\n    // première écriture a lieu.\n    let paintedStart = Number.NaN;\n    let paintedEnd = Number.NaN;\n\n    const paintBuffer = () => {\n      const { bufferedStart, bufferedEnd } = playhead.getSnapshot();\n      const start = toFraction(bufferedStart, min, max);\n      // Jamais de largeur négative, même si les deux bornes se croisent le\n      // temps d'une mesure.\n      const end = Math.max(toFraction(bufferedEnd, min, max), start);\n      if (start !== paintedStart) {\n        paintedStart = start;\n        wrapper.style.setProperty(BUFFER_START_PROPERTY, String(start));\n      }\n      if (end !== paintedEnd) {\n        paintedEnd = end;\n        wrapper.style.setProperty(BUFFER_END_PROPERTY, String(end));\n      }\n    };\n\n    paintBuffer();\n    return playhead.subscribe(paintBuffer);\n    // Les bornes changent au plus une fois par seconde, y compris en direct :\n    // ce réabonnement ne coûte rien, et il garde l'aperçu du buffer dans la\n    // même plage que la poignée.\n  }, [enabled, max, min, playhead]);\n\n  if (!enabled) return null;\n\n  // En direct, une fenêtre trop courte ne se cherche pas ; ailleurs, c'est la\n  // durée qui doit être connue.\n  const seekable = isLive ? span >= MIN_LIVE_SEEKABLE_WINDOW : finite;\n\n  // « 42 seconds of 9 minutes 56 seconds, Installation ». En direct, la durée\n  // totale n'existe pas : c'est le retard sur le bord qu'on annonce, la seule\n  // mesure qui ait un sens sur un flux sans fin. Sans durée ni fenêtre — les\n  // métadonnées ne sont pas arrivées —, la position seule : « of 0 seconds »\n  // serait faux.\n  //\n  // Le libellé du chapitre en dernier, après le temps : c'est la seule partie\n  // qui ne change pas à chaque seconde, et un lecteur d'écran qui réannonce en\n  // continu doit donner d'abord ce qu'on lui demande. Le chapitre de la valeur\n  // annoncée, et non celui de la lecture : au clavier comme sous le doigt, ce\n  // qu'on entend doit décrire là où on va.\n  const getValueText = (value: number) => {\n    if (isLive) {\n      // Ici c'est la valeur annoncée qu'on qualifie, et non l'état du lecteur :\n      // la tolérance suffit, à un segment près du bout de la fenêtre.\n      const delay = max - value;\n      return delay <= LIVE_EDGE_TOLERANCE ? \"Live\" : `${formatSpokenTime(delay)} behind live`;\n    }\n    const time = finite\n      ? `${formatSpokenTime(value)} of ${formatSpokenTime(duration)}`\n      : formatSpokenTime(value);\n    const index = findChapterIndex(chapters, value);\n    return index === -1 ? time : `${time}, ${chapters[index].label}`;\n  };\n\n  // Un dixième de la plage parcourue — la vidéo entière, ou la fenêtre du\n  // direct —, jamais moins qu'une flèche. Fini même quand la durée ne l'est\n  // pas : `PageUp` ne doit pas envoyer à l'infini.\n  const pageStep =\n    Number.isFinite(span) && span > 0 ? Math.max(span * 0.1, DEFAULT_SEEK_STEP) : DEFAULT_SEEK_STEP;\n\n  return (\n    <div ref={wrapperRef} data-slot=\"video-player-scrubber\">\n      <PlayerSlider\n        aria-label=\"Seek\"\n        min={min}\n        max={max}\n        value={second}\n        position={position}\n        step={DEFAULT_SEEK_STEP}\n        pageStep={pageStep}\n        getValueText={getValueText}\n        disabled={!seekable}\n        onValueChange={onValueChange}\n      >\n        {/*\n          Pas de `PlayerSliderTrack` ici, et c'est la seule différence avec le\n          curseur de volume : la piste du scrubber est faite de segments, et une\n          piste haute de quatre pixels ne pourrait pas en laisser un dépasser au\n          survol. `ChapterSegments` la remplace et tient toute la hauteur.\n        */}\n        <ChapterSegments>\n          {/* Avant la partie jouée, pour passer dessous. */}\n          <div\n            data-slot=\"video-player-scrubber-buffer\"\n            className=\"absolute inset-y-0 left-[calc(var(--player-buffer-start,0)*100%)] w-[calc((var(--player-buffer-end,0)_-_var(--player-buffer-start,0))*100%)] bg-foreground/40\"\n          />\n          <PlayerSliderRange />\n        </ChapterSegments>\n      </PlayerSlider>\n    </div>\n  );\n});\n",
      "type": "registry:ui",
      "target": "@ui/video-player/player-scrubber.tsx"
    },
    {
      "path": "registry/videocn/chapter-segments.tsx",
      "content": "\"use client\";\n\nimport { useLayoutEffect, useRef, type ReactNode } from \"react\";\n\nimport { useChapters } from \"./chapters-context\";\nimport { useControlsOptions } from \"./controls-context\";\n\n/**\n * Le découpage de la piste en segments de chapitres, et la piste elle-même.\n *\n * Ce fichier ne dessine aucune donnée. Il reçoit les couches du scrubber en\n * `children` et les réplique dans chaque segment : le scrubber reste\n * propriétaire de ce qui est peint — l'aperçu du buffer, la partie jouée — et\n * n'a jamais à savoir où tombent les coupures.\n *\n * **Chaque segment est une fenêtre sur la piste entière.** La barre visible\n * clippe ; le calque qu'elle contient reconstitue la largeur totale et se\n * recale dessous. Les couches répliquées lisent alors\n * `--player-slider-fraction` et `--player-buffer-start/end` telles quelles,\n * sans une seule opération de plus. C'est ce qui permet au curseur de garder un\n * écrivain unique pour sa fraction : un découpage qui aurait exigé une fraction\n * par segment aurait demandé de la réécrire autant de fois, soixante fois par\n * seconde.\n *\n * **Trois boîtes par chapitre, et chacune a une raison.** La *zone* fait toute\n * la hauteur du curseur et ne se voit pas : c'est elle qu'on survole, parce\n * qu'une barre de quatre pixels ne se vise pas. La *barre* est ce qu'on voit,\n * centrée dans la zone. Le *calque*, dedans, porte la piste entière re-cadrée.\n *\n * **Les zones se touchent, l'écart est pris sur la barre.** Rien à survoler\n * entre deux chapitres, donc : l'épaisseur ne retombe pas le temps d'un pixel\n * quand on balaie la barre.\n *\n * **L'écart est retranché de la largeur de la barre, jamais posé en marge.** Le\n * calque se comprime alors d'autant, et la partie jouée vaut exactement zéro au\n * début d'un chapitre et exactement la largeur visible à sa fin. Une marge, à\n * l'inverse, aurait laissé le calque à la largeur de la piste : le remplissage\n * aurait dérivé d'un écart cumulé, de plus en plus faux vers la fin.\n *\n * **Aucune branche « sans chapitres ».** Rien à découper — pas de liste, durée\n * inconnue, direct, ou option coupée — rend un segment unique de 0 à 1 avec un\n * écart nul, et le calcul redonne exactement la piste d'un seul tenant. Le\n * chemin par défaut est donc celui qu'on regarde tous les jours ; il ne peut\n * pas pourrir sans qu'on le voie.\n */\n\nconst START_PROPERTY = \"--chapter-start\";\nconst SPAN_PROPERTY = \"--chapter-span\";\nconst GAP_PROPERTY = \"--chapter-gap\";\n\n/**\n * L'écart entre deux barres, et son absence après la dernière.\n *\n * Deux pixels : la plus petite coupure qui se lise encore sur une piste haute\n * de quatre. Et rien après le dernier segment — la barre s'arrêterait deux\n * pixels avant son bord, et la fin de la vidéo ne serait jamais atteinte à\n * l'œil, alors que c'est précisément le moment où on la regarde.\n */\nconst SEGMENT_GAP = \"2px\";\nconst NO_GAP = \"0px\";\n\n/**\n * Ce que le découpage lit d'un chapitre : deux fractions, rien d'autre.\n *\n * `ResolvedChapter` s'y conforme, et la piste d'un seul tenant aussi — sans\n * qu'on ait à fabriquer un faux chapitre avec des secondes et un libellé vides\n * dont personne ne saurait quoi faire.\n */\ninterface Segment {\n  fraction: number;\n  span: number;\n}\n\n/** La piste d'un seul tenant, dite comme un chapitre unique qui couvre tout. */\nconst WHOLE_TRACK: readonly Segment[] = Object.freeze([Object.freeze({ fraction: 0, span: 1 })]);\n\nexport interface ChapterSegmentsProps {\n  /** Les couches à répliquer dans chaque segment. */\n  children: ReactNode;\n}\n\nexport function ChapterSegments({ children }: ChapterSegmentsProps) {\n  const chapters = useChapters();\n  const { scrubber } = useControlsOptions();\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  // Deux références stables et rien d'autre : celle que le contexte garde pour\n  // toute la vidéo, ou la constante du module. L'effet ci-dessous ne tourne\n  // donc qu'au changement de vidéo, et non à chaque seconde rendue par le\n  // scrubber.\n  const segments: readonly Segment[] =\n    scrubber.chapters && chapters.length > 0 ? chapters : WHOLE_TRACK;\n\n  // Un seul chapitre ne se distingue pas de lui-même : sans découpage, survoler\n  // la barre l'épaissit une fois, comme avant les chapitres. Sans cette\n  // réserve, une vidéo sans chapitres verrait sa barre monter à dix pixels.\n  const distinguishable = segments.length > 1;\n\n  // Un seul effet plutôt qu'une ref par segment : la liste ne change qu'avec la\n  // vidéo, et chaque élément garde au plus un écrivain impératif — la règle qui\n  // vaut déjà pour la fraction du curseur et pour les bornes du buffer.\n  //\n  // En layout effect, comme la fraction du curseur : le rendu qui suit\n  // l'arrivée de la durée fait apparaître les N segments d'un coup, tous sur\n  // leurs valeurs par défaut, c'est-à-dire empilés à gauche et larges comme la\n  // piste. Écrire après la peinture montrerait cette frame-là.\n  useLayoutEffect(() => {\n    const container = containerRef.current;\n    if (!container) return;\n    const last = segments.length - 1;\n    segments.forEach((segment, index) => {\n      const element = container.children[index];\n      if (!(element instanceof HTMLElement)) return;\n      // Sans unité : ce sont des nombres, et `--chapter-span` sert de diviseur.\n      element.style.setProperty(START_PROPERTY, String(segment.fraction));\n      element.style.setProperty(SPAN_PROPERTY, String(segment.span));\n      element.style.setProperty(GAP_PROPERTY, index === last ? NO_GAP : SEGMENT_GAP);\n    });\n  }, [segments]);\n\n  return (\n    // La piste tient toute la hauteur du curseur, et c'est elle qu'on survole :\n    // le verrou de l'épaisseur générale est donc ici, et non sur la racine du\n    // curseur, qu'on n'a pas à toucher. `data-dragging` le tient aussi, sans\n    // quoi la barre maigrirait dès que le doigt sort du lecteur en glissant.\n    <div\n      data-slot=\"video-player-scrubber-chapters\"\n      ref={containerRef}\n      className=\"absolute inset-0 hover:[--scrubber-lift:0.125rem] group-data-dragging/slider:[--scrubber-lift:0.125rem]\"\n    >\n      {segments.map((segment) => (\n        // Le début du chapitre comme clé : il est unique par construction — deux\n        // chapitres au même instant ne survivent pas à la normalisation.\n        //\n        // La zone de survol : toute la hauteur, aucune apparence, et elle touche\n        // ses voisines. `items-center` centre la barre sans `translate`, que la\n        // conversion RTL du CLI réécrirait.\n        <div\n          key={segment.fraction}\n          data-slot=\"video-player-scrubber-chapter\"\n          className={\n            distinguishable\n              ? \"absolute inset-y-0 left-[calc(var(--chapter-start,0)*100%)] flex w-[calc(var(--chapter-span,1)*100%)] items-center hover:[--chapter-lift:0.25rem]\"\n              : \"absolute inset-y-0 left-[calc(var(--chapter-start,0)*100%)] flex w-[calc(var(--chapter-span,1)*100%)] items-center\"\n          }\n        >\n          {/* La barre visible. Un plancher de deux pixels : un chapitre plus\n              court que l'écart donnerait une largeur négative, donc une barre\n              absente. Mieux vaut une marque trop large qu'un trou. */}\n          <div\n            data-slot=\"video-player-scrubber-chapter-bar\"\n            // L'épaisseur est une somme, et c'est délibéré : **deux variables\n            // posées sur deux éléments différents**, jamais deux classes de\n            // hauteur sur le même. À spécificité égale, entre `hover:` et\n            // `group-hover/…`, c'est l'ordre de génération qui tranche — une\n            // loterie dont dépendrait l'épaisseur de la barre. La piste pose la\n            // sienne, le chapitre visé pose la sienne, et l'addition n'a plus\n            // rien à départager : 4 px au repos, 6 px sur la barre, 10 px sur\n            // le chapitre survolé. L'écart entre 6 et 10 est voulu large : à\n            // deux pixels près, on ne voyait pas lequel on visait.\n            // Le rayon vient de l'hôte et non d'un `rounded-full` : sur une\n            // barre de quatre pixels, le navigateur écrête le rayon à la moitié\n            // de la hauteur, donc `var(--radius)` y dessine exactement la même\n            // pilule. La différence n'apparaît qu'au bout de l'échelle — un\n            // thème à `--radius: 0` rend un angle vif, comme partout chez lui.\n            className=\"relative h-[calc(0.25rem_+_var(--scrubber-lift,0rem)_+_var(--chapter-lift,0rem))] w-[max(calc(100%_-_var(--chapter-gap,0px)),2px)] shrink-0 overflow-hidden rounded-lg bg-foreground/20 transition-[height] duration-150 motion-reduce:transition-none\"\n          >\n            {/*\n              Le calque : la piste entière, reconstituée à l'intérieur de la\n              barre et remontée sous sa fenêtre. Il est clippé par\n              l'`overflow-hidden` ci-dessus, ce qui laisse ses couches raisonner\n              en fractions de la vidéo entière, comme si les chapitres\n              n'existaient pas.\n            */}\n            <div\n              data-slot=\"video-player-scrubber-chapter-canvas\"\n              className=\"absolute inset-y-0 left-[calc(var(--chapter-start,0)/var(--chapter-span,1)*-100%)] w-[calc(100%/var(--chapter-span,1))]\"\n            >\n              {children}\n            </div>\n          </div>\n        </div>\n      ))}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/video-player/chapter-segments.tsx"
    },
    {
      "path": "registry/videocn/play-toggle.tsx",
      "content": "\"use client\";\n\nimport { memo } from \"react\";\nimport { PauseIcon, PlayIcon } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\n\nimport { useControlsOptions } from \"./controls-context\";\nimport { usePlayerActions, usePlayerValue } from \"./player-context\";\n\n/**\n * Un seul bouton, dont le libellé change — et pas d'`aria-pressed`. « Pause »\n * décrit ce que le bouton va faire ; un état pressé décrirait ce qu'il est, et\n * les deux ensemble se contredisent à l'oreille : « Pause, activé ».\n */\nexport const PlayToggle = memo(function PlayToggle() {\n  const { play, keyboard } = useControlsOptions();\n  const paused = usePlayerValue((state) => state.paused);\n  const { togglePlay } = usePlayerActions();\n\n  if (!play.enabled) return null;\n\n  return (\n    <Button\n      variant=\"ghost\"\n      size=\"icon\"\n      onClick={togglePlay}\n      aria-label={paused ? \"Play\" : \"Pause\"}\n      aria-keyshortcuts={keyboard.enabled ? \"k\" : undefined}\n    >\n      {paused ? <PlayIcon /> : <PauseIcon />}\n    </Button>\n  );\n});\n",
      "type": "registry:ui",
      "target": "@ui/video-player/play-toggle.tsx"
    },
    {
      "path": "registry/videocn/volume-control.tsx",
      "content": "\"use client\";\n\nimport { memo, useRef } from \"react\";\nimport { Volume1Icon, Volume2Icon, VolumeXIcon } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\n\nimport { useControlsOptions } from \"./controls-context\";\nimport { DEFAULT_VOLUME_STEP } from \"./controls-options\";\nimport { usePlayerActions, usePlayerValue } from \"./player-context\";\nimport {\n  PlayerSlider,\n  PlayerSliderRange,\n  PlayerSliderTrack,\n  type SliderChangeReason,\n} from \"./player-slider\";\n\n/**\n * Le volume : la bascule muet et le curseur partagé, sur une plage de 0 à 1.\n * Aucune prop, comme les autres contrôles. Pas de `position` : le volume ne\n * bouge qu'au geste, sa `value` suffit à le dessiner.\n */\n\n/** Ce qu'`Escape` rétablit : le volume **et** l'état muet d'avant le geste. */\ninterface VolumeSnapshot {\n  volume: number;\n  muted: boolean;\n}\n\nexport const VolumeControl = memo(function VolumeControl() {\n  const { volume: volumeOptions, keyboard } = useControlsOptions();\n  const volume = usePlayerValue((state) => state.volume);\n  const muted = usePlayerValue((state) => state.muted);\n  const canControlVolume = usePlayerValue((state) => state.canControlVolume);\n  const { setVolume, setMuted, stepVolume, toggleMuted } = usePlayerActions();\n  const beforeGestureRef = useRef<VolumeSnapshot | null>(null);\n\n  if (!volumeOptions.enabled) return null;\n\n  const effectiveVolume = muted ? 0 : volume;\n  const VolumeIcon =\n    effectiveVolume === 0\n      ? VolumeXIcon\n      : effectiveVolume < 0.5\n        ? Volume1Icon\n        : Volume2Icon;\n\n  const handleValueChange = (next: number, reason: SliderChangeReason) => {\n    if (reason === \"start\") {\n      beforeGestureRef.current = { volume, muted };\n    }\n    if (reason === \"cancel\" && beforeGestureRef.current) {\n      // La valeur d'avant l'appui que renvoie le curseur ne suffit pas : depuis\n      // l'état muet elle vaut 0, et le geste a pu rétablir le son en chemin.\n      const before = beforeGestureRef.current;\n      beforeGestureRef.current = null;\n      setVolume(before.volume);\n      setMuted(before.muted);\n      return;\n    }\n    if (reason === \"key\") {\n      // Une touche passe par la même action que la keymap du lecteur : depuis\n      // le muet, `↑` rend le volume d'avant la coupure au lieu de repartir des\n      // 0 % affichés. Le pas se lit dans l'écart à la valeur affichée.\n      stepVolume(next - effectiveVolume);\n      return;\n    }\n    setVolume(next);\n    // Bouger le curseur depuis l'état muet rétablit le son.\n    if (muted && next > 0) {\n      setMuted(false);\n    }\n  };\n\n  return (\n    // `peer/volume` : l'horodatage, qui suit ce contrôle dans la même rangée,\n    // s'efface dans une barre étroite pendant que le volet est déplié (voir\n    // `time-display.tsx`). Il recopie les trois conditions du volet ci-dessous\n    // — survol, focus clavier, glissement — et suppose de rester son voisin\n    // immédiat : réordonner la barre casserait le masquage sans erreur.\n    <div className=\"group/volume peer/volume flex min-w-0 items-center\">\n      <Button\n        variant=\"ghost\"\n        size=\"icon\"\n        onClick={toggleMuted}\n        aria-label={muted ? \"Unmute\" : \"Mute\"}\n        aria-keyshortcuts={keyboard.enabled ? \"m\" : undefined}\n      >\n        <VolumeIcon />\n      </Button>\n      {/*\n        Le volet qui replie le curseur. Seule sa largeur s'anime, en CSS : il\n        s'ouvre quand le pointeur est sur le groupe, quand le focus clavier y\n        est, et tant qu'un glissement est en cours — le pointeur peut alors\n        sortir de la zone sans que le curseur ne se referme sous le doigt.\n\n        Le curseur reste dans le DOM, replié : il garde sa place dans la\n        tabulation, et le focus l'ouvre. Sans survol possible (`hover: none`),\n        il n'est jamais affiché : toucher l'icône bascule le muet, et un curseur\n        de 96 px ne se manie pas au doigt dans une barre qui en compte déjà dix.\n\n        `overflow-hidden` a une largeur minimale nulle en flex, et le `min-w-0`\n        de la racine lui laisse la même latitude : dans une barre étroite, le\n        volet se comprime au lieu de la faire déborder.\n      */}\n      <div className=\"w-0 overflow-hidden transition-[width] duration-200 group-hover/volume:w-24 group-has-focus-visible/volume:w-24 group-has-data-dragging/volume:w-24 motion-reduce:transition-none [@media(hover:none)]:hidden\">\n        {/*\n          Le curseur prend toute la largeur de son parent : c'est cette\n          enveloppe qui lui donne la sienne, plutôt qu'une classe posée sur le\n          composant. La marge laisse la place au thumb, centré sur la position,\n          qui déborde de la moitié de sa taille — et de son anneau de focus — à\n          0 % et à 100 % : l'`overflow-hidden` du volet les couperait.\n        */}\n        <div className=\"mx-2.5\">\n          <PlayerSlider\n            aria-label=\"Volume\"\n            min={0}\n            max={1}\n            value={effectiveVolume}\n            step={DEFAULT_VOLUME_STEP}\n            pageStep={0.2}\n            getValueText={(value) => (muted ? \"Muted\" : `${Math.round(value * 100)}%`)}\n            // Sur iPhone, Safari ignore les écritures sur `video.volume` : le\n            // curseur mentirait. Le bouton muet, lui, fonctionne — il reste actif.\n            disabled={!canControlVolume}\n            onValueChange={handleValueChange}\n          >\n            <PlayerSliderTrack>\n              <PlayerSliderRange />\n            </PlayerSliderTrack>\n          </PlayerSlider>\n        </div>\n      </div>\n    </div>\n  );\n});\n",
      "type": "registry:ui",
      "target": "@ui/video-player/volume-control.tsx"
    },
    {
      "path": "registry/videocn/time-display.tsx",
      "content": "\"use client\";\n\nimport { memo } from \"react\";\n\nimport { useControlsOptions } from \"./controls-context\";\nimport { formatSpokenTime, formatTime } from \"./format-time\";\nimport { usePlayerValue, usePlayheadValue } from \"./player-context\";\nimport { displayedTime, type PlayheadSnapshot } from \"./playhead-store\";\nimport { selectIsLive, type PlayerState } from \"./player-state-store\";\n\n/**\n * La seconde entière et non le temps exact : c'est tout ce que l'horodatage\n * affiche, et le sélecteur ne réveille le composant que lorsqu'elle change —\n * un rendu par seconde de média, pas soixante.\n *\n * `displayedTime` fait suivre le doigt pendant un glissement plutôt que la\n * vidéo, qui ne cherche qu'à intervalles : l'horodatage reste d'accord avec la\n * poignée du scrubber.\n */\nfunction selectDisplayedSecond(snapshot: PlayheadSnapshot): number {\n  return Math.floor(displayedTime(snapshot));\n}\n\n/**\n * Le retard sur le bord du direct, à la seconde. Arrondi et non tronqué : c'est\n * un écart et non une position, et 41,6 s de retard s'annoncent « 42 » ;\n * arrondi tout court, pour la même raison que la seconde ci-dessus — la tête de\n * lecture avance soixante fois par seconde et le bord glisse avec elle.\n *\n * Hors direct, la valeur n'est pas lue : elle change au même rythme que la\n * seconde affichée, donc elle ne provoque aucun rendu de plus.\n */\nfunction selectLiveDelay(snapshot: PlayheadSnapshot): number {\n  return Math.round(snapshot.seekableEnd - displayedTime(snapshot));\n}\n\n/**\n * Hors direct, le retard sur le bord n'a aucun sens — et s'y abonner coûterait\n * un rendu de plus par seconde : il change à contretemps de la seconde\n * affichée, si bien que l'horodatage se réveillait deux fois par seconde de\n * média au lieu d'une. Mesuré. Un sélecteur constant coupe l'abonnement.\n */\nfunction selectNoDelay(): number {\n  return 0;\n}\n\n/** Au bord, c'est la tête de lecture qui tranche : voir `atLiveEdge`. */\nfunction selectAtLiveEdge(snapshot: PlayheadSnapshot): boolean {\n  return snapshot.atLiveEdge;\n}\n\nfunction selectDuration(state: PlayerState): number {\n  return state.duration;\n}\n\n/**\n * Le vrai signe moins (U+2212) et non le trait d'union : avec `tabular-nums`,\n * il a la chasse d'un chiffre et l'horodatage ne se déhanche pas quand le\n * retard apparaît. Il n'est jamais prononcé — le lecteur d'écran reçoit la\n * forme parlée, en toutes lettres.\n */\nconst MINUS_SIGN = \"−\";\n\n/**\n * Au bord du direct, un tiret cadratin plutôt qu'un vide : la place reste\n * prise, et tomber en retard ne fait pas naître un bloc de texte au milieu de\n * la barre.\n */\nconst NO_DELAY = \"—\";\n\n/**\n * Les deux écritures d'un même instant. `spoken` est vide quand il n'y a rien à\n * annoncer, au bord du direct : la pastille « Live » porte déjà l'information,\n * et la répéter ne ferait qu'allonger la lecture de la barre.\n */\ninterface TimeLabels {\n  visual: string;\n  spoken: string;\n}\n\nfunction vodLabels(current: number, duration: number): TimeLabels {\n  // En direct, la durée vaut `Infinity` ; avant les métadonnées, zéro. Dans les\n  // deux cas il n'y a pas de total à afficher, seulement le temps courant.\n  const hasDuration = Number.isFinite(duration) && duration > 0;\n  return {\n    visual: hasDuration\n      ? `${formatTime(current, duration)} / ${formatTime(duration, duration)}`\n      : formatTime(current, duration),\n    spoken: hasDuration\n      ? `${formatSpokenTime(current)} of ${formatSpokenTime(duration)}`\n      : formatSpokenTime(current),\n  };\n}\n\nfunction liveLabels(delay: number, atEdge: boolean): TimeLabels {\n  // La même décision que la pastille, prise au même endroit : deux calculs\n  // séparés feraient dire « Live » à l'une pendant que l'autre affiche un\n  // retard.\n  if (atEdge) return { visual: NO_DELAY, spoken: \"\" };\n  return {\n    visual: `${MINUS_SIGN}${formatTime(delay)}`,\n    spoken: `${formatSpokenTime(delay)} behind live`,\n  };\n}\n\n/**\n * L'horodatage `0:42 / 9:56`, ou le retard sur le direct — `−0:42` — sur un\n * flux sans fin. Aucune prop, comme les autres contrôles.\n *\n * Pas de région live : une annonce par seconde rendrait le lecteur d'écran\n * inutilisable. Le texte se lit quand on vient le chercher, et c'est le\n * scrubber qui annonce la position quand on la change.\n */\nexport const TimeDisplay = memo(function TimeDisplay() {\n  const { time } = useControlsOptions();\n  const current = usePlayheadValue(selectDisplayedSecond);\n  const duration = usePlayerValue(selectDuration);\n  const isLive = usePlayerValue(selectIsLive);\n  const liveDelay = usePlayheadValue(isLive ? selectLiveDelay : selectNoDelay);\n  const atLiveEdge = usePlayheadValue(selectAtLiveEdge);\n\n  if (!time.enabled) return null;\n\n  const { visual, spoken } = isLive\n    ? liveLabels(liveDelay, atLiveEdge)\n    : vodLabels(current, duration);\n\n  return (\n    <span\n      data-slot=\"video-player-time\"\n      // Des chiffres, comme le scrubber qu'il accompagne : ils se lisent de\n      // gauche à droite dans toutes les langues. Sans ça, l'algorithme bidi\n      // d'un projet RTL affiche `9:56 / 0:00`.\n      dir=\"ltr\"\n      // `tabular-nums` : sans chiffres à chasse fixe, le texte change de\n      // largeur à chaque seconde et vibre sous les yeux.\n      //\n      // Dans une barre de moins de 30rem de contenu (lecteur < ≈ 506 px), le\n      // curseur de volume déplié n'aurait que quelques pixels de piste : tant\n      // qu'il est déplié, l'horodatage passe en `sr-only` — masqué à l'écran,\n      // toujours lu. Le seuil laisse ≈ 54 px de marge à une vidéo de moins\n      // d'une heure avec chapitres (≈ 426 px nécessaires, `59:59 / 59:59`).\n      // Les trois conditions recopient celles du volet de `volume-control.tsx`,\n      // dont ce `span` doit rester le voisin immédiat (`peer/volume`) ; le\n      // focus est restreint à `hover: hover` comme le volet, qu'un appareil sans\n      // survol n'affiche jamais.\n      className=\"mx-2 text-sm whitespace-nowrap tabular-nums @max-[30rem]:peer-hover/volume:sr-only @max-[30rem]:peer-has-data-dragging/volume:sr-only @max-[30rem]:[@media(hover:hover)]:peer-has-focus-visible/volume:sr-only\"\n    >\n      {/* Deux écritures, une pour chaque canal. Lu tel quel, `9:56` devient\n          « neuf deux-points cinquante-six », ou une heure de la journée : les\n          yeux reçoivent la forme compacte, le lecteur d'écran la forme parlée.\n          Aucune des deux n'est une région live, le texte se lit quand on vient\n          le chercher. */}\n      <span aria-hidden=\"true\">{visual}</span>\n      {spoken ? <span className=\"sr-only\">{spoken}</span> : null}\n    </span>\n  );\n});\n",
      "type": "registry:ui",
      "target": "@ui/video-player/time-display.tsx"
    },
    {
      "path": "registry/videocn/live-badge.tsx",
      "content": "\"use client\";\n\nimport { memo, type ReactElement } from \"react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"cn\";\n\nimport { useControlsOptions } from \"./controls-context\";\nimport { usePlayerActions, usePlayerValue, usePlayheadValue } from \"./player-context\";\nimport { selectIsLive } from \"./player-state-store\";\nimport type { PlayheadSnapshot } from \"./playhead-store\";\n\n/**\n * La pastille « Live » : elle dit si l'on regarde le bord du direct, et permet\n * d'y revenir quand on l'a quitté. Aucune prop, comme les autres contrôles.\n *\n * Elle n'existe que sur un flux en direct : en vidéo à la demande, le bord n'a\n * aucun sens et la place revient aux contrôles qui en ont un.\n */\n\n/**\n * Un booléen, décidé par la tête de lecture : elle seule connaît le retard de\n * croisière du flux, et elle ne réveille la pastille qu'aux bascules — un\n * sélecteur qui renverrait des secondes la rendrait soixante fois par seconde.\n */\nfunction selectAtLiveEdge(snapshot: PlayheadSnapshot): boolean {\n  return snapshot.atLiveEdge;\n}\n\nexport const LiveBadge = memo(function LiveBadge(): ReactElement | null {\n  const { live } = useControlsOptions();\n  const isLive = usePlayerValue(selectIsLive);\n  const atEdge = usePlayheadValue(selectAtLiveEdge);\n  const { goToLive } = usePlayerActions();\n\n  if (!live.enabled || !isLive) return null;\n\n  return (\n    <Button\n      variant=\"ghost\"\n      // Pas de `size=\"icon\"` : il y a un libellé, et c'est lui qui donne son\n      // nom au bouton.\n      onClick={atEdge ? undefined : goToLive}\n      // `disabled` et non un bouton qui ne ferait rien : au bord du direct, il\n      // n'y a nulle part où aller, et c'est l'état désactivé qui le dit à un\n      // lecteur d'écran — « Live, dimmed » — sans qu'on ait à l'écrire.\n      disabled={atEdge}\n      // Le nom dit ce que le bouton fait quand il est actif, et reprend le\n      // libellé visible pour que le contrôle vocal puisse le viser. Au bord, le\n      // libellé suffit : l'état est porté par `disabled`.\n      aria-label={atEdge ? undefined : \"Go to live\"}\n      // L'opacité de `disabled` est neutralisée : ici l'état désactivé est\n      // l'état *allumé*, celui qu'on doit voir le mieux. Sans ça, la pastille\n      // du bord serait plus pâle que celle du retard, exactement à l'envers de\n      // ce qu'elle raconte.\n      className={cn(atEdge ? \"disabled:opacity-100\" : \"text-muted-foreground\")}\n    >\n      {/* Un point et non une icône : deux états de couleur, rien d'autre à\n          dessiner. `aria-hidden` parce qu'il ne dit rien de plus que le\n          libellé qui le suit. */}\n      <span\n        aria-hidden=\"true\"\n        className={cn(\n          // Le rayon de l'hôte : sur huit pixels il est écrêté et donne le\n          // même point rond, mais un thème anguleux obtient un carré.\n          \"size-2 shrink-0 rounded-lg\",\n          atEdge ? \"bg-destructive\" : \"bg-muted-foreground\",\n        )}\n      />\n      Live\n    </Button>\n  );\n});\n",
      "type": "registry:ui",
      "target": "@ui/video-player/live-badge.tsx"
    },
    {
      "path": "registry/videocn/chapter-menu.tsx",
      "content": "\"use client\";\n\nimport { memo, type ReactElement } from \"react\";\nimport { ListIcon } from \"lucide-react\";\n\nimport { useActiveChapterIndex, useChapters } from \"./chapters-context\";\nimport { useControlsOptions } from \"./controls-context\";\nimport { formatSpokenTime, formatTime } from \"./format-time\";\nimport { usePlayerActions, usePlayerValue } from \"./player-context\";\nimport {\n  PlayerMenu,\n  PlayerMenuContent,\n  PlayerMenuRadioItem,\n  PlayerMenuTrigger,\n} from \"./player-menu\";\nimport type { PlayerState } from \"./player-state-store\";\n\n/**\n * Le menu des chapitres : la liste des titres, et un clic pour sauter au\n * début de l'un d'eux.\n *\n * **Il disparaît quand il n'y a rien à lister**, là où le sélecteur de qualité\n * reste affiché et grisé. Les deux règles ne se contredisent pas, elles\n * suivent la donnée : toute vidéo a une qualité — un MP4 progressif\n * n'en expose simplement aucune, et le bouton grisé dit justement ça —, alors\n * que les chapitres sont une donnée optionnelle que la plupart des vidéos\n * n'auront jamais. Un bouton mort sur chaque lecteur serait du bruit permanent\n * pour une fonction rare.\n *\n * La liste arrive déjà normalisée, triée et bornée par le contexte, et elle est\n * vide dès que la durée est inconnue — le direct tombe dans ce cas tout seul.\n * Il n'y a donc ici aucun cas particulier à traiter : quand on rend quelque\n * chose, la durée est finie et les horodatages sont calculables.\n */\n\nfunction selectDuration(state: PlayerState): number {\n  return state.duration;\n}\n\nexport const ChapterMenu = memo(function ChapterMenu(): ReactElement | null {\n  const { chapters: chapterOptions } = useControlsOptions();\n  const chapters = useChapters();\n  const activeIndex = useActiveChapterIndex();\n  const duration = usePlayerValue(selectDuration);\n  const { seek } = usePlayerActions();\n\n  if (!chapterOptions.enabled || chapters.length === 0) return null;\n\n  // `-1` avant les premières métadonnées, ou si la tête de lecture n'est pas\n  // encore retombée dans un chapitre : le bouton s'annonce alors sans titre.\n  const active = activeIndex === -1 ? null : chapters[activeIndex];\n\n  return (\n    <PlayerMenu>\n      {/* Icône seule, sans libellé visible : le titre du chapitre courant\n          change au fil de la lecture, et avec lui la largeur du bouton — les\n          cibles cliquables voisines se déplaceraient sous le doigt. C'est la\n          raison qui range déjà l'horodatage après le volume. Le nom\n          accessible, lui, peut porter le titre : il ne mesure rien. */}\n      <PlayerMenuTrigger aria-label={active ? `Chapters, ${active.label}` : \"Chapters\"}>\n        <ListIcon />\n      </PlayerMenuTrigger>\n      <PlayerMenuContent>\n        {chapters.map((chapter, index) => (\n          // `PlayerMenuRadioItem` et non un item simple : le chapitre courant\n          // est un état, il se coche — et le menu s'ouvre alors tout seul sur\n          // lui, sans qu'on ait à parcourir la liste pour se retrouver.\n          // L'item coché reste sélectionnable : y cliquer reprend le chapitre\n          // à son début, ce qu'on vient souvent chercher ici.\n          <PlayerMenuRadioItem\n            key={chapter.start}\n            checked={index === activeIndex}\n            onSelect={() => seek(chapter.start)}\n          >\n            {/* Le titre garde la direction de la page : il vient de\n                l'intégrateur et peut être écrit en arabe. Il vient aussi en\n                premier, ce qui laisse la saisie rapide du menu — qui compare\n                le texte de l'item — filtrer sur le titre. */}\n            <span>{chapter.label}</span>\n            {/* La durée en seconde référence et non le début : toute la\n                colonne prend alors la même écriture, et `0:42` ne voisine pas\n                `1:02:13`. `dir=\"ltr\"` parce que ce sont des chiffres — sous\n                une page RTL, l'algorithme bidi afficherait `42:0`. */}\n            <span\n              aria-hidden=\"true\"\n              dir=\"ltr\"\n              className=\"ml-auto shrink-0 text-xs whitespace-nowrap text-muted-foreground tabular-nums\"\n            >\n              {formatTime(chapter.start, duration)}\n            </span>\n            {/* Lu tel quel, `2:15` devient « deux deux-points quinze » ou une\n                heure de la journée. Le lecteur d'écran reçoit donc la forme\n                parlée à la place — à la place du seul horodatage, pas du\n                titre : ce que l'utilisateur lit doit rester ce qu'il peut\n                dire. */}\n            <span className=\"sr-only\">{formatSpokenTime(chapter.start)}</span>\n          </PlayerMenuRadioItem>\n        ))}\n      </PlayerMenuContent>\n    </PlayerMenu>\n  );\n});\n",
      "type": "registry:ui",
      "target": "@ui/video-player/chapter-menu.tsx"
    },
    {
      "path": "registry/videocn/settings-menu.tsx",
      "content": "\"use client\";\n\nimport {\n  memo,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type ReactElement,\n} from \"react\";\nimport { ChevronLeftIcon, ChevronRightIcon, SettingsIcon } from \"lucide-react\";\n\nimport { useControlsOptions } from \"./controls-context\";\nimport { usePlayerActions, usePlayerValue } from \"./player-context\";\nimport {\n  focusInitialItem,\n  PlayerMenu,\n  PlayerMenuContent,\n  PlayerMenuItem,\n  PlayerMenuRadioItem,\n  PlayerMenuTrigger,\n} from \"./player-menu\";\nimport type { PlayerState } from \"./player-state-store\";\n\n/**\n * Le menu de réglages : un bouton, un popup à deux niveaux. La racine liste les\n * réglages avec leur valeur courante, et chaque ligne ouvre sa liste de choix.\n * Il remplace les deux boutons de vitesse et de qualité d'avant — leurs\n * libellés tenaient trop de place dans une barre étroite.\n *\n * Aucune prop : le contrôle lit ses options et son état dans les contextes.\n * `playbackRate` et `quality` décident des lignes ; sans l'une ni l'autre, il\n * se rend `null`.\n *\n * **La ligne « Quality » reste affichée quand le moteur n'expose aucune\n * qualité**, et passe grisée — c'est le cas d'un MP4 progressif, où le\n * navigateur ne dit rien du contenu du fichier. Une ligne qui disparaît\n * déplace les autres et laisse croire à une panne ; grisée, elle dit qu'il n'y\n * a rien à choisir ici. Le moteur ne se connaît pas ici : on lit seulement ses\n * capacités.\n */\n\ntype View = \"root\" | \"speed\" | \"quality\";\n\n/** Le libellé du choix automatique, seul ou suivi de ce qui est joué. */\nconst AUTO = \"Auto\";\n\n/**\n * Point décimal et signe « × », quelle que soit la locale du navigateur : la\n * vitesse n'est pas une mesure mais une étiquette, et `0,5×` à côté de `1×`\n * dans la même liste donnerait deux écritures pour une même idée. Le `×` plutôt\n * que la lettre `x` parce qu'un lecteur d'écran le dit « times ».\n */\nfunction formatRate(rate: number): string {\n  return `${rate}×`;\n}\n\nfunction selectCapabilities(state: PlayerState) {\n  return state.capabilities;\n}\n\nexport const SettingsMenu = memo(function SettingsMenu(): ReactElement | null {\n  const { playbackRate: rateOptions, quality: qualityOptions } = useControlsOptions();\n  const capabilities = usePlayerValue(selectCapabilities);\n\n  if (!rateOptions.enabled && !qualityOptions.enabled) return null;\n\n  // Seule la qualité peut être grisée. Quand elle l'est et qu'elle est seule, le\n  // popup ne mènerait nulle part : c'est alors le bouton qui se grise.\n  const hasActionableRow =\n    rateOptions.enabled || (qualityOptions.enabled && capabilities.qualities.length > 0);\n\n  return (\n    <PlayerMenu>\n      <PlayerMenuTrigger aria-label=\"Settings\" disabled={!hasActionableRow}>\n        <SettingsIcon />\n      </PlayerMenuTrigger>\n      {/* Plus large que le défaut : « Quality » et « Auto (720p) » sur une même\n          ligne, chevron compris. */}\n      <PlayerMenuContent className=\"min-w-48\">\n        <SettingsPanel />\n      </PlayerMenuContent>\n    </PlayerMenu>\n  );\n});\n\n/**\n * Le contenu du popup. Monté seulement ouvert, donc `view` repart de la racine\n * à chaque ouverture sans qu'il y ait rien à remettre à zéro.\n */\nfunction SettingsPanel(): ReactElement {\n  const { playbackRate: rateOptions, quality: qualityOptions } = useControlsOptions();\n  const playbackRate = usePlayerValue((state) => state.playbackRate);\n  const capabilities = usePlayerValue(selectCapabilities);\n  const { setPlaybackRate, selectQuality } = usePlayerActions();\n  const [view, setView] = useState<View>(\"root\");\n  const panelRef = useRef<HTMLDivElement>(null);\n  const previousViewRef = useRef<View>(\"root\");\n\n  // Le focus était sur l'item de la vue précédente, que React vient de\n  // démonter : il est retombé sur le `body`. On le repose avant la peinture.\n  useLayoutEffect(() => {\n    const previous = previousViewRef.current;\n    previousViewRef.current = view;\n    // Au montage, c'est le popup qui pose le focus.\n    if (previous === view) return;\n\n    const panel = panelRef.current;\n    if (!panel) return;\n    if (view === \"root\") {\n      // On revient sur la ligne d'où l'on est parti.\n      const rows = panel.querySelectorAll<HTMLElement>('[role=\"menuitem\"]');\n      rows[previous === \"quality\" && rateOptions.enabled ? 1 : 0]?.focus();\n      return;\n    }\n    focusInitialItem(panel, \"checked\");\n  }, [rateOptions.enabled, view]);\n\n  const { qualities, activeQualityId, playingQualityId } = capabilities;\n  const playing = qualities.find((level) => level.id === playingQualityId);\n  const selected = qualities.find((level) => level.id === activeQualityId);\n\n  // `←`/`→` sont arrêtés par le popup pour que la vidéo n'avance pas ; ici on\n  // leur donne un sens, celui des chevrons : en RTL, ils sont retournés et les\n  // touches avec eux. La direction se lit à chaque touche, sans état, pour\n  // suivre un `dir` changé en cours de route (`:dir()` lève avant Chrome 120).\n  // Le popup les arrête encore après nous.\n  const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    const rtl = getComputedStyle(event.currentTarget).direction === \"rtl\";\n    const openKey = rtl ? \"ArrowLeft\" : \"ArrowRight\";\n    const backKey = rtl ? \"ArrowRight\" : \"ArrowLeft\";\n    if (event.key === openKey && view === \"root\") {\n      const active = document.activeElement;\n      // Un clic : la ligne focalisée sait elle-même quelle vue ouvrir.\n      if (active instanceof HTMLElement && panelRef.current?.contains(active)) active.click();\n    } else if (event.key === backKey && view !== \"root\") {\n      setView(\"root\");\n    }\n  };\n\n  return (\n    // `role=\"none\"` : cette enveloppe n'existe que pour porter la ref et les\n    // touches, le menu doit continuer à contenir directement ses items.\n    <div ref={panelRef} role=\"none\" onKeyDown={handleKeyDown}>\n      {view === \"root\" ? (\n        <>\n          {rateOptions.enabled ? (\n            <PlayerMenuItem onSelect={() => setView(\"speed\")}>\n              <span>Speed</span>\n              <span dir=\"ltr\" className=\"ml-auto text-muted-foreground\">\n                {formatRate(playbackRate)}\n              </span>\n              <RowChevron />\n            </PlayerMenuItem>\n          ) : null}\n          {qualityOptions.enabled ? (\n            <PlayerMenuItem disabled={qualities.length === 0} onSelect={() => setView(\"quality\")}>\n              <span>Quality</span>\n              {/* En automatique, la hauteur jouée : c'est la seule façon de\n                  savoir ce qu'on regarde sans quitter l'auto. */}\n              <span dir=\"ltr\" className=\"ml-auto text-muted-foreground\">\n                {selected ? selected.label : playing ? `${AUTO} (${playing.label})` : AUTO}\n              </span>\n              <RowChevron />\n            </PlayerMenuItem>\n          ) : null}\n        </>\n      ) : null}\n      {view === \"speed\" ? (\n        <>\n          <BackItem title=\"Speed\" onSelect={() => setView(\"root\")} />\n          {rateOptions.rates.map((rate) => (\n            <PlayerMenuRadioItem\n              key={rate}\n              // Rien n'oblige la vitesse courante à figurer dans la liste — la\n              // vidéo peut arriver avec la sienne. Aucun item n'est alors coché,\n              // et la liste s'ouvre sur la ligne de retour.\n              checked={rate === playbackRate}\n              onSelect={() => setPlaybackRate(rate)}\n            >\n              {/* `dir=\"ltr\"` : sous une page RTL, l'algorithme bidi afficherait\n                  `×1` au lieu de `1×`. */}\n              <span dir=\"ltr\">{formatRate(rate)}</span>\n            </PlayerMenuRadioItem>\n          ))}\n        </>\n      ) : null}\n      {view === \"quality\" ? (\n        <>\n          <BackItem title=\"Quality\" onSelect={() => setView(\"root\")} />\n          <PlayerMenuRadioItem\n            checked={activeQualityId === null}\n            onSelect={() => selectQuality(null)}\n          >\n            <span dir=\"ltr\">{playing ? `${AUTO} (${playing.label})` : AUTO}</span>\n          </PlayerMenuRadioItem>\n          {qualities.map((level) => (\n            <PlayerMenuRadioItem\n              key={level.id}\n              checked={level.id === activeQualityId}\n              onSelect={() => selectQuality(level.id)}\n            >\n              <span dir=\"ltr\">{level.label}</span>\n            </PlayerMenuRadioItem>\n          ))}\n        </>\n      ) : null}\n    </div>\n  );\n}\n\n/** Le chevron de fin de ligne, dans la place que `pr-8` réserve à l'item. */\nfunction RowChevron(): ReactElement {\n  return (\n    <span className=\"pointer-events-none absolute right-2 flex items-center justify-center\">\n      {/* Retourné en RTL : la ligne s'ouvre alors vers l'autre bord. */}\n      <ChevronRightIcon className=\"rtl:rotate-180\" />\n    </span>\n  );\n}\n\n/** La première ligne d'une liste : son titre, et le retour à la racine. */\nfunction BackItem({ title, onSelect }: { title: string; onSelect: () => void }): ReactElement {\n  return (\n    <PlayerMenuItem className=\"font-medium\" onSelect={onSelect}>\n      <ChevronLeftIcon className=\"rtl:rotate-180\" />\n      <span>{title}</span>\n    </PlayerMenuItem>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/video-player/settings-menu.tsx"
    },
    {
      "path": "registry/videocn/picture-in-picture-toggle.tsx",
      "content": "\"use client\";\n\nimport { memo } from \"react\";\nimport { PictureInPicture2Icon, PictureInPictureIcon } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\n\nimport { useControlsOptions } from \"./controls-context\";\nimport { usePlayerActions, usePlayerValue } from \"./player-context\";\n\n/**\n * Firefox n'implémente pas l'API standard et l'iPhone n'a pas de\n * Picture-in-Picture du tout : `canPictureInPicture` est faux plus souvent\n * qu'ailleurs. Le bouton reste là, grisé, comme les autres.\n */\nexport const PictureInPictureToggle = memo(function PictureInPictureToggle() {\n  const { pictureInPicture } = useControlsOptions();\n  const canPictureInPicture = usePlayerValue((state) => state.canPictureInPicture);\n  const isPictureInPicture = usePlayerValue((state) => state.isPictureInPicture);\n  const { togglePictureInPicture } = usePlayerActions();\n\n  if (!pictureInPicture.enabled) return null;\n\n  return (\n    <Button\n      variant=\"ghost\"\n      size=\"icon\"\n      disabled={!canPictureInPicture}\n      onClick={togglePictureInPicture}\n      aria-label={\n        isPictureInPicture ? \"Exit picture-in-picture\" : \"Enter picture-in-picture\"\n      }\n    >\n      {isPictureInPicture ? <PictureInPictureIcon /> : <PictureInPicture2Icon />}\n    </Button>\n  );\n});\n",
      "type": "registry:ui",
      "target": "@ui/video-player/picture-in-picture-toggle.tsx"
    },
    {
      "path": "registry/videocn/fullscreen-toggle.tsx",
      "content": "\"use client\";\n\nimport { memo } from \"react\";\nimport { MaximizeIcon, MinimizeIcon } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\n\nimport { useControlsOptions } from \"./controls-context\";\nimport { usePlayerActions, usePlayerValue } from \"./player-context\";\n\n/**\n * Indisponible, le bouton passe `disabled` — il n'est jamais démonté. C'est la\n * règle du sélecteur de qualité grisé, appliquée ici : un contrôle qui\n * disparaît déplace tous les autres et laisse croire à une panne.\n */\nexport const FullscreenToggle = memo(function FullscreenToggle() {\n  const { fullscreen, keyboard } = useControlsOptions();\n  const canFullscreen = usePlayerValue((state) => state.canFullscreen);\n  const isFullscreen = usePlayerValue((state) => state.isFullscreen);\n  const { toggleFullscreen } = usePlayerActions();\n\n  if (!fullscreen.enabled) return null;\n\n  return (\n    <Button\n      variant=\"ghost\"\n      size=\"icon\"\n      disabled={!canFullscreen}\n      onClick={toggleFullscreen}\n      aria-label={isFullscreen ? \"Exit fullscreen\" : \"Enter fullscreen\"}\n      aria-keyshortcuts={keyboard.enabled ? \"f\" : undefined}\n    >\n      {isFullscreen ? <MinimizeIcon /> : <MaximizeIcon />}\n    </Button>\n  );\n});\n",
      "type": "registry:ui",
      "target": "@ui/video-player/fullscreen-toggle.tsx"
    }
  ],
  "cssVars": {
    "light": {
      "player-scrim": "oklch(0 0 0 / 60%)",
      "player-backdrop": "oklch(0 0 0)"
    },
    "dark": {
      "player-scrim": "oklch(0 0 0 / 70%)",
      "player-backdrop": "oklch(0 0 0)"
    }
  },
  "docs": "Drop it in and it works:\n\n  import { VideoCn } from \"@/components/ui/video-player/video-cn\"\n\n  <VideoCn src=\"/clip.mp4\" />\n\nEverything is tuned through props — never by editing the code you just received:\n\n  <VideoCn\n    src=\"https://example.com/stream.m3u8\"\n    poster=\"/poster.jpg\"\n    defaultVolume={0.5}\n    controls={{\n      pictureInPicture: false,\n      playbackRate: { rates: [1, 1.5, 2] },\n      autoHideDelay: 2000,\n    }}\n  />\n\nChapters are a prop as well — a start time and a title, in any order:\n\n  <VideoCn\n    src=\"/talk.mp4\"\n    chapters={[\n      { time: 0, label: \"Introduction\" },\n      { time: 135, label: \"Setting things up\" },\n      { time: 450, label: \"Questions\" },\n    ]}\n  />\n\nThey segment the progress bar and fill a menu. Live streams ignore them.\n\nThe source type is detected from the URL. Pass type=\"hls\" | \"dash\" | \"native\" when\nthe URL is signed or has no extension. Adjust the import path to match your aliases.",
  "type": "registry:ui"
}