249 lines · 10.0 KB
import {
  ClosedCaptioningIcon,
  CornersOutIcon,
  PauseIcon,
  PlayIcon,
  SpeakerHighIcon,
  SpeakerXIcon,
} from "@phosphor-icons/react";
import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
import { useHlsSource, useScrubManifest } from "~/hooks/use-video";
import { scrubFrameAt, videoUrl, type ScrubManifest } from "~/lib/video";
import { cn } from "~/utils/cn";

type Caption = { src: string; srcLang: string; label: string };
type Props = { id: string; version: string; title: string; captions?: Caption[]; className?: string };

const SEEK_STEP_SECONDS = 5;
const THUMB_WIDTH = 160;

function clock(seconds: number): string {
  if (!Number.isFinite(seconds)) return "0:00";
  const whole = Math.floor(seconds);
  return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
}

function spriteStyle(manifest: ScrubManifest, id: string, version: string, time: number): CSSProperties {
  const frame = scrubFrameAt(manifest, time);
  const column = frame.x / manifest.frameWidth;
  const row = frame.y / manifest.frameHeight;
  const percent = (index: number, count: number) => (count > 1 ? (index / (count - 1)) * 100 : 0);
  return {
    backgroundImage: `url(${videoUrl(id, version, frame.sheet)})`,
    backgroundSize: `${manifest.columns * 100}% ${manifest.rows * 100}%`,
    backgroundPosition: `${percent(column, manifest.columns)}% ${percent(row, manifest.rows)}%`,
  };
}

/** HLS player served from R2 via /videos; dragging the timeline shows sprite frames instantly and seeks on release. */
export function VideoPlayer({ id, version, title, captions = [], className }: Props) {
  const [video, setVideo] = useState<HTMLVideoElement | null>(null);