Repository Public
1"use client";23import { useCallback, useEffect, useRef, useState } from "react";45export interface PlayerLabels {6 play: string; pause: string; mute: string; unmute: string; captions: string; captionsOff: string; replay: string; progress: string;7 buffering: string; error: string; retry: string; lowData: string; tapUnmute: string; offline: string;8}9export interface PlayerFilm { id: string; title: string; video: string; videoLow: string | null; poster: string; captions: { en: string | null; fr: string | null }; duration: number }10interface Cue { start: number; end: number; text: string }11export type CaptionChoice = "off" | "en" | "fr";1213function parseVtt(vtt: string): Cue[] {14 const sec = (t: string) => { const p = t.split(":").map(Number); return p.length === 3 ? p[0] * 3600 + p[1] * 60 + p[2] : p[0] * 60 + p[1]; };15 const cues: Cue[] = [];16 for (const block of vtt.replace(/\r/g, "").split(/\n\n+/)) {17 const lines = block.split("\n");18 const i = lines.findIndex((l) => l.includes("-->"));19 if (i < 0) continue;20 const [a, b] = lines[i].split("-->").map((x) => x.trim().split(" ")[0]);21 cues.push({ start: sec(a), end: sec(b), text: lines.slice(i + 1).join(" ").trim() });22 }23 return cues;24}2526const PREF = "clip:prefs";27export function readPrefs(): { muted: boolean; captions: CaptionChoice | null } {28 try { return { muted: true, captions: null, ...JSON.parse(localStorage.getItem(PREF) ?? "{}") }; } catch { return { muted: true, captions: null }; }29}30function writePrefs(p: Partial<{ muted: boolean; captions: CaptionChoice }>) {31 try { localStorage.setItem(PREF, JSON.stringify({ ...readPrefs(), ...p })); } catch { /* private mode */ }32}3334const Icon = ({ d, size = 22 }: { d: string; size?: number }) => (35 <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={d} /></svg>36);37export const ICONS = {38 play: "M7 4.5v15l12-7.5z", pause: "M8 5v14M16 5v14",39 volume: "M4 9.5v5h3.5L12 18V6L7.5 9.5zM15.5 9a4.2 4.2 0 0 1 0 6M18 6.5a7.6 7.6 0 0 1 0 11",40 muted: "M4 9.5v5h3.5L12 18V6L7.5 9.5zM16 9.5l5 5M21 9.5l-5 5",41 cc: "M4 6h16v12H4zM10 10.5a2 2 0 1 0 0 3M16.5 10.5a2 2 0 1 0 0 3",42 replay: "M4 12a8 8 0 1 0 2.6-5.9M4 4v4.5h4.5",43};4445/**46 * Film player: tap to pause, sound toggle, custom word-timed captions (EN/FR), seekable progress,47 * buffering / error / offline states, a low-data rendition, and no autoplay under reduced motion.48 */49export function Player({ film, labels, locale, active = true, autoplay = true, loop = false, preload = "metadata", onEnded, onFirstPlay, onComplete, className = "", controls = "full", chromeClass = "top-3", captionClass = "bottom-[15%]" }: {50 film: PlayerFilm; labels: PlayerLabels; locale: "en" | "fr"; active?: boolean; autoplay?: boolean; loop?: boolean; preload?: "none" | "metadata" | "auto";51 onEnded?: () => void; onFirstPlay?: () => void; onComplete?: () => void; className?: string; controls?: "full" | "minimal"; chromeClass?: string; captionClass?: string;52}) {53 const ref = useRef<HTMLVideoElement>(null);54 const [playing, setPlaying] = useState(false);55 const [muted, setMuted] = useState(true);56 const [cap, setCap] = useState<CaptionChoice>(locale);57 const [cues, setCues] = useState<Cue[]>([]);58 const [time, setTime] = useState(0);59 const [buffering, setBuffering] = useState(false);60 const [error, setError] = useState(false);61 const [ended, setEnded] = useState(false);62 const [low, setLow] = useState(false);63 const [offline, setOffline] = useState(false);64 const started = useRef(false);65 const completed = useRef(false);6667 useEffect(() => {68 const p = readPrefs();69 setMuted(p.muted);70 if (p.captions) setCap(p.captions);71 const c = (navigator as Navigator & { connection?: { saveData?: boolean; effectiveType?: string } }).connection;72 if (film.videoLow && (c?.saveData || /2g|3g/.test(c?.effectiveType ?? ""))) setLow(true);73 const on = () => setOffline(!navigator.onLine);74 on();75 window.addEventListener("online", on); window.addEventListener("offline", on);76 return () => { window.removeEventListener("online", on); window.removeEventListener("offline", on); };77 }, [film.videoLow]);7879 useEffect(() => {80 const url = cap === "off" ? null : film.captions[cap] ?? film.captions.en ?? film.captions.fr;81 if (!url) { setCues([]); return; }82 let alive = true;83 fetch(url).then((r) => (r.ok ? r.text() : "")).then((t) => { if (alive) setCues(parseVtt(t)); }).catch(() => { if (alive) setCues([]); });84 return () => { alive = false; };85 }, [cap, film.captions]);8687 // Play only the active film; honour reduced motion (no autoplay).88 useEffect(() => {89 const v = ref.current;90 if (!v) return;91 if (!active) { v.pause(); return; }92 const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;93 if (autoplay && !reduce && !error) {94 v.muted = readPrefs().muted;95 v.play().catch(() => { v.muted = true; setMuted(true); v.play().catch(() => {}); });96 }97 }, [active, autoplay, error, low]);9899 const toggle = useCallback(() => {100 const v = ref.current;101 if (!v) return;102 if (v.ended) { v.currentTime = 0; setEnded(false); }103 if (v.paused) v.play().catch(() => {}); else v.pause();104 }, []);105 const toggleMute = useCallback(() => {106 setMuted((m) => { const next = !m; if (ref.current) ref.current.muted = next; writePrefs({ muted: next }); return next; });107 }, []);108 const cycleCaptions = useCallback(() => {109 setCap((c) => { const order: CaptionChoice[] = locale === "fr" ? ["fr", "en", "off"] : ["en", "fr", "off"]; const next = order[(order.indexOf(c) + 1) % order.length]; writePrefs({ captions: next }); return next; });110 }, [locale]);111112 // Keyboard shortcuts act on the active player only.113 useEffect(() => {114 if (!active) return;115 const onKey = (e: KeyboardEvent) => {116 const t = e.target as HTMLElement;117 if (t.closest("input, textarea, select, [contenteditable]") || e.metaKey || e.ctrlKey || e.altKey) return;118 if (e.key === " " || e.key === "k") { if (t.closest("button, a")) return; e.preventDefault(); toggle(); }119 else if (e.key === "m") toggleMute();120 else if (e.key === "c") cycleCaptions();121 else if (e.key === "ArrowRight" && ref.current) ref.current.currentTime = Math.min(ref.current.duration || 0, ref.current.currentTime + 5);122 else if (e.key === "ArrowLeft" && ref.current) ref.current.currentTime = Math.max(0, ref.current.currentTime - 5);123 };124 window.addEventListener("keydown", onKey);125 return () => window.removeEventListener("keydown", onKey);126 }, [active, toggle, toggleMute, cycleCaptions]);127128 const duration = ref.current?.duration || film.duration;129 const cue = cues.find((c) => time >= c.start && time <= c.end);130 const retry = () => { setError(false); if (film.videoLow && !low) setLow(true); else ref.current?.load(); };131132 return (133 <div className={`relative h-full w-full select-none bg-black ${className}`}>134 <video135 ref={ref} key={low ? "low" : "hd"} src={low && film.videoLow ? film.videoLow : film.video} poster={film.poster} playsInline muted={muted} loop={loop} preload={preload}136 className="h-full w-full object-contain" aria-label={film.title}137 onPlay={() => { setPlaying(true); setEnded(false); if (!started.current) { started.current = true; onFirstPlay?.(); } }}138 onPause={() => setPlaying(false)} onWaiting={() => setBuffering(true)} onPlaying={() => setBuffering(false)} onCanPlay={() => setBuffering(false)}139 onTimeUpdate={(e) => { const v = e.currentTarget; setTime(v.currentTime); if (!completed.current && v.duration && v.currentTime / v.duration > 0.92) { completed.current = true; onComplete?.(); } }}140 onEnded={() => { setEnded(true); setPlaying(false); onEnded?.(); }}141 onError={() => { setBuffering(false); if (film.videoLow && !low) setLow(true); else setError(true); }}142 />143 <button type="button" onClick={toggle} aria-label={playing ? labels.pause : labels.play} className="absolute inset-0 z-10 cursor-pointer" tabIndex={controls === "minimal" ? -1 : 0} />144145 {cue && cap !== "off" && (146 <div className={`pointer-events-none absolute inset-x-0 z-20 px-5 text-center [text-wrap:balance] ${captionClass}`} lang={cap}>147 <span className="caption-line text-[clamp(1rem,2.6vh,1.35rem)] font-medium text-white">{cue.text}</span>148 </div>149 )}150151 {buffering && !error && playing && <div className="pointer-events-none absolute inset-0 z-20 grid place-items-center" role="status" aria-label={labels.buffering}><div className="spinner" /></div>}152 {!playing && !ended && !error && !buffering && (153 <div className="pointer-events-none absolute inset-0 z-20 grid place-items-center"><div className="grid h-16 w-16 place-items-center rounded-full bg-black/55 text-white backdrop-blur-sm"><Icon d={ICONS.play} size={28} /></div></div>154 )}155 {ended && !loop && (156 <div className="absolute inset-0 z-30 grid place-items-center bg-black/45"><button type="button" onClick={toggle} className="btn bg-white/95 text-black"><Icon d={ICONS.replay} size={18} />{labels.replay}</button></div>157 )}158 {error && (159 <div className="absolute inset-0 z-30 grid place-items-center bg-black/80 p-6 text-center" role="alert">160 <div><p className="mb-4 text-sm text-white/80">{offline ? labels.offline : labels.error}</p><button type="button" onClick={retry} className="btn bg-white text-black">{labels.retry}</button></div>161 </div>162 )}163164 <div className={`absolute right-3 z-30 flex flex-col gap-2 ${chromeClass}`}>165 <button type="button" onClick={toggleMute} aria-label={muted ? labels.unmute : labels.mute} aria-pressed={!muted} className="grid h-11 w-11 place-items-center rounded-full bg-black/55 text-white backdrop-blur-sm"><Icon d={muted ? ICONS.muted : ICONS.volume} /></button>166 {controls === "full" && (167 <button type="button" onClick={cycleCaptions} aria-label={`${labels.captions}: ${cap === "off" ? labels.captionsOff : cap.toUpperCase()}`} className="relative grid h-11 w-11 place-items-center rounded-full bg-black/55 text-white backdrop-blur-sm">168 <Icon d={ICONS.cc} /><span className="absolute -bottom-1 -right-1 rounded bg-[var(--color-lamp)] px-1 text-[0.6rem] font-bold uppercase text-[var(--color-on-lamp)]">{cap === "off" ? "—" : cap}</span>169 </button>170 )}171 </div>172 {muted && playing && controls === "full" && time < 4 && (173 <button type="button" onClick={toggleMute} className="absolute left-1/2 top-4 z-30 -translate-x-1/2 rounded-full bg-black/60 px-3 py-1.5 text-xs text-white backdrop-blur-sm">{labels.tapUnmute}</button>174 )}175176 {controls === "full" && (177 <div className="absolute inset-x-0 bottom-0 z-30 px-3 pb-2">178 <input179 type="range" min={0} max={Math.max(duration, 0.1)} step={0.05} value={Math.min(time, duration)} aria-label={labels.progress}180 aria-valuetext={`${Math.round(time)} / ${Math.round(duration)} s`}181 onChange={(e) => { const t = Number(e.target.value); setTime(t); if (ref.current) ref.current.currentTime = t; }}182 className="h-5 w-full cursor-pointer accent-[var(--color-lamp)]" style={{ accentColor: "var(--color-lamp)" }}183 />184 </div>185 )}186 </div>187 );188}189