195 lines · 7.0 KB · TypeScript
RawDownload
import { RotateCwIcon, TriangleAlertIcon } from "lucide-react";import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; import { cn } from "../../lib/utils";import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";import { Button } from "../ui/button";import { OpenMediaLink } from "./OpenMediaLink";import { MediaActions, type MediaActionSource } from "./MediaActions"; interface MediaVideoPlayerProps {  readonly src: string | null;  readonly label: string;  readonly sourceFailed?: boolean | undefined;  readonly originalUrl?: string | undefined;  readonly revision?: string | null | undefined;  readonly preload?: "visible" | "metadata" | undefined;  readonly autoPlay?: boolean | undefined;  readonly className?: string | undefined;  readonly videoClassName?: string | undefined;  /** Styles the loading and failure panels, which otherwise assume an inline light surface. */  readonly stateClassName?: string | undefined;  readonly style?: CSSProperties | undefined;  readonly copyMarkdown?: string | undefined;  readonly onRetry?: (() => Promise<void>) | undefined;  readonly actionsSource?: MediaActionSource | undefined;} /** Keeps native range streaming and playback state consistent across inline and file previews. */export function MediaVideoPlayer({  src: latestSrc,  label,  sourceFailed = false,  originalUrl,  revision = null,  preload = "visible",  autoPlay = false,  className,  videoClassName,  stateClassName,  style,  copyMarkdown,  onRetry,  actionsSource,}: MediaVideoPlayerProps) {  const videoRef = useRef<HTMLVideoElement>(null);  const [playbackSource, setPlaybackSource] = useState<{    src: string;    revision: string | null;  } | null>(null);  const [failedSrc, setFailedSrc] = useState<string | null>(null);  const [retrying, setRetrying] = useState(false);  const [loadAttempt, setLoadAttempt] = useState(0);  const [preloadedSrc, setPreloadedSrc] = useState<string | null>(null);  const src = playbackSource?.src ?? latestSrc;  const sourceRevision = playbackSource === null ? revision : playbackSource.revision;  const failed = src !== null ? failedSrc === src : sourceFailed;   // Re-signing must not reset the playhead. Changed files refresh once playback pauses.  const refreshPausedRevision = useCallback(() => {    const video = videoRef.current;    if (video === null || video.paused || video.ended) {      setPlaybackSource((current) =>        current !== null && current.revision !== revision ? null : current,      );    }  }, [revision]);  useEffect(refreshPausedRevision, [refreshPausedRevision]);   useEffect(() => {    const video = videoRef.current;    if (!video || preload === "metadata" || preloadedSrc === src) return;    if (typeof IntersectionObserver === "undefined") {      setPreloadedSrc(src);      return;    }    let active = true;    const observer = new IntersectionObserver(      (entries) => {        if (!active || !entries.some((entry) => entry.isIntersecting)) return;        setPreloadedSrc(src);        observer.disconnect();      },      { rootMargin: "200px" },    );    observer.observe(video);    return () => {      active = false;      observer.disconnect();    };  }, [src, preload, preloadedSrc, failed, loadAttempt]);   useEffect(() => {    const video = videoRef.current;    if (!video) return;    const pauseWhenHidden = () => {      // Native fullscreen can hide the inline page while this video is still visible.      const fullscreen =        document.fullscreenElement?.contains(video) ||        ("webkitDisplayingFullscreen" in video && video.webkitDisplayingFullscreen === true);      if (document.hidden && !fullscreen) video.pause();    };    document.addEventListener("visibilitychange", pauseWhenHidden);    document.addEventListener("fullscreenchange", pauseWhenHidden);    video.addEventListener("webkitendfullscreen", pauseWhenHidden);    return () => {      document.removeEventListener("visibilitychange", pauseWhenHidden);      document.removeEventListener("fullscreenchange", pauseWhenHidden);      video.removeEventListener("webkitendfullscreen", pauseWhenHidden);      video.pause();    };  }, [src, failed, loadAttempt]);   const retry = async () => {    if (retrying) return;    setRetrying(true);    try {      await onRetry?.();      setPlaybackSource(null);      setFailedSrc(null);      setLoadAttempt((current) => current + 1);    } catch {      setFailedSrc(src);    } finally {      setRetrying(false);    }  };   const player = (    <span      className={cn("relative inline-block align-middle", className)}      style={style}      data-markdown-copy={copyMarkdown}    >      {failed ? (        <span          role="alert"          className={cn(            // Same 16:9 slot as the loading and playing states, so a failed or            // retried video does not move the rows below it.            "flex aspect-video max-h-full min-h-28 w-full flex-col items-center justify-center gap-3 rounded-lg border border-border/40 bg-muted/40 p-4 text-center text-sm text-muted-foreground",            stateClassName,          )}        >          <span className="inline-flex items-center gap-1.5">            <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" />            Video unavailable{label ? ` · ${label}` : ""}          </span>          <span className="flex flex-wrap items-center justify-center gap-2">            {latestSrc !== null || onRetry ? (              <Button                size="sm"                variant="secondary"                disabled={retrying}                onClick={() => void retry()}              >                <RotateCwIcon />                {retrying ? "Retrying…" : "Retry video"}              </Button>            ) : null}            <OpenMediaLink originalUrl={originalUrl} src={latestSrc ?? src} fileName={label} />          </span>        </span>      ) : src !== null ? (        <video          key={loadAttempt}          ref={videoRef}          src={src}          aria-label={label || "Video preview"}          autoPlay={autoPlay}          controls          playsInline          preload={preload === "metadata" || preloadedSrc === src ? "metadata" : "none"}          className={cn("aspect-video max-h-full w-full bg-black object-contain", videoClassName)}          style={style}          onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)}          onPlay={() => setPlaybackSource({ src, revision: sourceRevision })}          onPause={refreshPausedRevision}          onEnded={refreshPausedRevision}          onError={() => {            if (latestSrc !== null && src !== latestSrc) setPlaybackSource(null);            else setFailedSrc(src);          }}        />      ) : (        <span          role="status"          aria-label="Loading video"          className={cn("block aspect-video w-full rounded-lg bg-muted/60", stateClassName)}          style={style}        />      )}    </span>  );  return actionsSource ? <MediaActions source={actionsSource}>{player}</MediaActions> : player;}