| 1 | import { PlayIcon, RotateCwIcon, TriangleAlertIcon } from "lucide-react"; |
| 2 | import { mediaUrlReference } from "@t3tools/client-runtime/media-reference"; |
| 3 | import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; |
| 4 | |
| 5 | import { cn } from "../../lib/utils"; |
| 6 | import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; |
| 7 | import { Button } from "../ui/button"; |
| 8 | import { OpenMediaLink } from "./OpenMediaLink"; |
| 9 | import { MediaActions, type MediaActionSource } from "./MediaActions"; |
| 10 | import { MediaPlaybackSlot } from "./MediaPlaybackHost"; |
| 11 | import { MediaVideoControls } from "./MediaVideoControls"; |
| 12 | import "./MediaVideoPlayer.css"; |
| 13 | |
| 14 | interface 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 | |
| 34 | export 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 | |
| 48 | function 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 | } |