SPB Git

Repository Public

Player.tsx 11.2 KB 189 lines tsx

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
Files main
Menu

Theme