1import { PlayIcon, RotateCwIcon, TriangleAlertIcon } from "lucide-react";
2import { mediaUrlReference } from "@t3tools/client-runtime/media-reference";
3import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
4
5import { cn } from "../../lib/utils";
6import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";
7import { Button } from "../ui/button";
8import { OpenMediaLink } from "./OpenMediaLink";
9import { MediaActions, type MediaActionSource } from "./MediaActions";
10import { MediaPlaybackSlot } from "./MediaPlaybackHost";
11import { MediaVideoControls } from "./MediaVideoControls";
12import "./MediaVideoPlayer.css";
13
14interface MediaVideoPlayerProps {
15 readonly src: string | null;
16 readonly label: string;
17 readonly sourceFailed?: boolean | undefined;
18 readonly originalUrl?: string | undefined;
19 readonly revision?: string | null | undefined;
20 readonly preload?: "visible" | "metadata" | undefined;
21 readonly autoPlay?: boolean | undefined;
22 /** Presents a still thumbnail whose full surface opens the video in a viewer. */
23 readonly onOpen?: (() => void) | undefined;
24 readonly className?: string | undefined;
25 readonly videoClassName?: string | undefined;
26 /** Styles the loading and failure panels, which otherwise assume an inline light surface. */
27 readonly stateClassName?: string | undefined;
28 readonly style?: CSSProperties | undefined;
29 readonly copyMarkdown?: string | undefined;
30 readonly onRetry?: (() => Promise<unknown>) | undefined;
31 readonly actionsSource?: MediaActionSource | undefined;
32}
33
34export function MediaVideoPlayer(props: MediaVideoPlayerProps) {
35 const player = <MediaVideoPlayerContent {...props} />;
36 if (props.onOpen) return player;
37
38 const source = props.actionsSource;
39 const identity = JSON.stringify([
40 source?.asset?.environmentId,
41 source?.asset?.resource ?? source?.reference ?? props.originalUrl ?? props.src ?? props.label,
42 props.label,
43 props.copyMarkdown,
44 ]);
45 return <MediaPlaybackSlot identity={identity}>{player}</MediaPlaybackSlot>;
46}
47
48function MediaVideoPlayerContent({
49 src: latestSrc,
50 label,
51 sourceFailed = false,
52 originalUrl,
53 revision = null,
54 preload = "visible",
55 autoPlay = false,
56 onOpen,
57 className,
58 videoClassName,
59 stateClassName,
60 style,
61 copyMarkdown,
62 onRetry,
63 actionsSource,
64}: MediaVideoPlayerProps) {
65 const videoRef = useRef<HTMLVideoElement>(null);
66 const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(null);
67 const attachVideo = useCallback((video: HTMLVideoElement | null) => {
68 videoRef.current = video;
69 setVideoElement(video);
70 }, []);
71 const [playbackSource, setPlaybackSource] = useState<{
72 src: string;
73 revision: string | null;
74 } | null>(null);
75 const [failedSrc, setFailedSrc] = useState<string | null>(null);
76 const [retrying, setRetrying] = useState(false);
77 const [loadAttempt, setLoadAttempt] = useState(0);
78 const [preloadedSrc, setPreloadedSrc] = useState<string | null>(null);
79 const [videoSize, setVideoSize] = useState<{ src: string; ratio: number } | null>(null);
80 const src = playbackSource?.src ?? latestSrc;
81 const sourceRevision = playbackSource === null ? revision : playbackSource.revision;
82 const failed = src !== null ? failedSrc === src : sourceFailed;
83 const fallbackReference = mediaUrlReference(originalUrl ?? src ?? "");
84
85 // Re-signing must not reset the playhead. Changed files refresh once playback pauses.
86 const refreshPausedRevision = useCallback(() => {
87 const video = videoRef.current;
88 if (video === null || video.paused || video.ended) {
89 setPlaybackSource((current) =>
90 current !== null && current.revision !== revision ? null : current,
91 );
92 }
93 }, [revision]);
94 useEffect(refreshPausedRevision, [refreshPausedRevision]);
95
96 useEffect(() => {
97 const video = videoRef.current;
98 if (!video || preload === "metadata" || preloadedSrc === src) return;
99 if (typeof IntersectionObserver === "undefined") {
100 setPreloadedSrc(src);
101 return;
102 }
103 let active = true;
104 const observer = new IntersectionObserver(
105 (entries) => {
106 if (!active || !entries.some((entry) => entry.isIntersecting)) return;
107 setPreloadedSrc(src);
108 observer.disconnect();
109 },
110 { rootMargin: "200px" },
111 );
112 observer.observe(video);
113 return () => {
114 active = false;
115 observer.disconnect();
116 };
117 }, [src, preload, preloadedSrc, failed, loadAttempt]);
118
119 useEffect(() => {
120 const video = videoRef.current;
121 if (!video) return;
122 const pauseWhenHidden = () => {
123 // Native fullscreen can hide the inline page while this video is still visible.
124 const fullscreen =
125 document.fullscreenElement?.contains(video) ||
126 ("webkitDisplayingFullscreen" in video && video.webkitDisplayingFullscreen === true);
127 if (document.hidden && !fullscreen && document.pictureInPictureElement !== video)
128 video.pause();
129 };
130 document.addEventListener("visibilitychange", pauseWhenHidden);
131 document.addEventListener("fullscreenchange", pauseWhenHidden);
132 video.addEventListener("webkitendfullscreen", pauseWhenHidden);
133 return () => {
134 document.removeEventListener("visibilitychange", pauseWhenHidden);
135 document.removeEventListener("fullscreenchange", pauseWhenHidden);
136 video.removeEventListener("webkitendfullscreen", pauseWhenHidden);
137 video.pause();
138 };
139 }, [src, failed, loadAttempt]);
140
141 const retry = async () => {
142 if (retrying) return;
143 setRetrying(true);
144 try {
145 await onRetry?.();
146 setPlaybackSource(null);
147 setFailedSrc(null);
148 setLoadAttempt((current) => current + 1);
149 } catch {
150 setFailedSrc(src);
151 } finally {
152 setRetrying(false);
153 }
154 };
155
156 const player = (
157 <span
158 data-media-player
159 className={cn("relative inline-flex flex-col align-middle", className)}
160 style={
161 {
162 ...style,
163 "--media-video-ratio": videoSize?.src === src ? videoSize.ratio : 16 / 9,
164 } as CSSProperties
165 }
166 data-markdown-copy={copyMarkdown}
167 >
168 {failed && !onOpen ? (
169 <span
170 role="alert"
171 className={cn(
172 "flex aspect-(--media-video-ratio) 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",
173 stateClassName,
174 )}
175 >
176 <span className="inline-flex items-center gap-1.5">
177 <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" />
178 Video unavailable{label ? ` · ${label}` : ""}
179 </span>
180 <span className="flex flex-wrap items-center justify-center gap-2">
181 {latestSrc !== null || onRetry ? (
182 <Button
183 size="sm"
184 variant="secondary"
185 disabled={retrying}
186 onClick={() => void retry()}
187 >
188 <RotateCwIcon />
189 {retrying ? "Retrying…" : "Retry video"}
190 </Button>
191 ) : null}
192 <OpenMediaLink originalUrl={originalUrl} src={latestSrc ?? src} fileName={label} />
193 </span>
194 </span>
195 ) : src !== null && !failed ? (
196 <video
197 key={loadAttempt}
198 ref={attachVideo}
199 src={src}
200 aria-label={label || "Video preview"}
201 aria-hidden={onOpen ? true : undefined}
202 autoPlay={onOpen ? false : autoPlay}
203 controls={false}
204 muted={onOpen ? true : undefined}
205 playsInline
206 preload={preload === "metadata" || preloadedSrc === src ? "metadata" : "none"}
207 className={cn(
208 "aspect-[auto_16/9] h-auto max-h-full w-full bg-black object-contain",
209 onOpen && "pointer-events-none",
210 videoClassName,
211 )}
212 style={style}
213 onLoadedMetadata={(event) => {
214 const video = event.currentTarget;
215 if (video.videoWidth > 0 && video.videoHeight > 0) {
216 setVideoSize({ src, ratio: video.videoWidth / video.videoHeight });
217 }
218 prepareVideoFirstFrame(video);
219 }}
220 onPlay={() => setPlaybackSource({ src, revision: sourceRevision })}
221 onPause={refreshPausedRevision}
222 onEnded={refreshPausedRevision}
223 onError={() => {
224 if (latestSrc !== null && src !== latestSrc) setPlaybackSource(null);
225 else setFailedSrc(src);
226 }}
227 />
228 ) : (
229 <span
230 role="status"
231 aria-label={failed ? "Video preview unavailable" : "Loading video"}
232 className={cn(
233 "block aspect-(--media-video-ratio) w-full rounded-lg bg-muted/60",
234 stateClassName,
235 )}
236 style={style}
237 />
238 )}
239 {!onOpen && videoElement && !failed ? (
240 <MediaVideoControls
241 video={videoElement}
242 actionsSource={
243 actionsSource ?? {
244 kind: "video",
245 name: label,
246 src,
247 ...(fallbackReference ? { reference: fallbackReference } : {}),
248 }
249 }
250 />
251 ) : null}
252 {onOpen ? (
253 <button
254 type="button"
255 aria-label={label ? `Play ${label}` : "Play video"}
256 onClick={onOpen}
257 className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-lg focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring"
258 >
259 <span className="flex size-8 items-center justify-center rounded-full bg-black/50 text-white">
260 <PlayIcon aria-hidden className="size-4 fill-current" />
261 </span>
262 </button>
263 ) : null}
264 </span>
265 );
266 return actionsSource ? <MediaActions source={actionsSource}>{player}</MediaActions> : player;
267}