| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { RotateCwIcon, TriangleAlertIcon } from "lucide-react"; | 1 | import { PlayIcon, RotateCwIcon, TriangleAlertIcon } from "lucide-react"; |
| 2 | import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; | 2 | import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; |
| 3 | | 3 | |
| 4 | import { cn } from "../../lib/utils"; | 4 | import { cn } from "../../lib/utils"; |
| ... | @@ -15,6 +15,8 @@ interface MediaVideoPlayerProps { | ... | @@ -15,6 +15,8 @@ interface MediaVideoPlayerProps { |
| 15 | readonly revision?: string | null | undefined; | 15 | readonly revision?: string | null | undefined; |
| 16 | readonly preload?: "visible" | "metadata" | undefined; | 16 | readonly preload?: "visible" | "metadata" | undefined; |
| 17 | readonly autoPlay?: boolean | undefined; | 17 | readonly autoPlay?: boolean | undefined; |
| | 18 | /** Presents a still thumbnail whose full surface opens the video in a viewer. */ |
| | 19 | readonly onOpen?: (() => void) | undefined; |
| 18 | readonly className?: string | undefined; | 20 | readonly className?: string | undefined; |
| 19 | readonly videoClassName?: string | undefined; | 21 | readonly videoClassName?: string | undefined; |
| 20 | /** Styles the loading and failure panels, which otherwise assume an inline light surface. */ | 22 | /** Styles the loading and failure panels, which otherwise assume an inline light surface. */ |
| ... | @@ -34,6 +36,7 @@ export function MediaVideoPlayer({ | ... | @@ -34,6 +36,7 @@ export function MediaVideoPlayer({ |
| 34 | revision = null, | 36 | revision = null, |
| 35 | preload = "visible", | 37 | preload = "visible", |
| 36 | autoPlay = false, | 38 | autoPlay = false, |
| | 39 | onOpen, |
| 37 | className, | 40 | className, |
| 38 | videoClassName, | 41 | videoClassName, |
| 39 | stateClassName, | 42 | stateClassName, |
| ... | @@ -131,7 +134,7 @@ export function MediaVideoPlayer({ | ... | @@ -131,7 +134,7 @@ export function MediaVideoPlayer({ |
| 131 | style={style} | 134 | style={style} |
| 132 | data-markdown-copy={copyMarkdown} | 135 | data-markdown-copy={copyMarkdown} |
| 133 | > | 136 | > |
| 134 | {failed ? ( | 137 | {failed && !onOpen ? ( |
| 135 | <span | 138 | <span |
| 136 | role="alert" | 139 | role="alert" |
| 137 | className={cn( | 140 | className={cn( |
| ... | @@ -160,17 +163,23 @@ export function MediaVideoPlayer({ | ... | @@ -160,17 +163,23 @@ export function MediaVideoPlayer({ |
| 160 | <OpenMediaLink originalUrl={originalUrl} src={latestSrc ?? src} fileName={label} /> | 163 | <OpenMediaLink originalUrl={originalUrl} src={latestSrc ?? src} fileName={label} /> |
| 161 | </span> | 164 | </span> |
| 162 | </span> | 165 | </span> |
| 163 | ) : src !== null ? ( | 166 | ) : src !== null && !failed ? ( |
| 164 | <video | 167 | <video |
| 165 | key={loadAttempt} | 168 | key={loadAttempt} |
| 166 | ref={videoRef} | 169 | ref={videoRef} |
| 167 | src={src} | 170 | src={src} |
| 168 | aria-label={label || "Video preview"} | 171 | aria-label={label || "Video preview"} |
| 169 | autoPlay={autoPlay} | 172 | aria-hidden={onOpen ? true : undefined} |
| 170 | controls | 173 | autoPlay={onOpen ? false : autoPlay} |
| | 174 | controls={!onOpen} |
| | 175 | muted={onOpen ? true : undefined} |
| 171 | playsInline | 176 | playsInline |
| 172 | preload={preload === "metadata" || preloadedSrc === src ? "metadata" : "none"} | 177 | preload={preload === "metadata" || preloadedSrc === src ? "metadata" : "none"} |
| 173 | className={cn("aspect-video max-h-full w-full bg-black object-contain", videoClassName)} | 178 | className={cn( |
| | 179 | "aspect-video max-h-full w-full bg-black object-contain", |
| | 180 | onOpen && "pointer-events-none", |
| | 181 | videoClassName, |
| | 182 | )} |
| 174 | style={style} | 183 | style={style} |
| 175 | onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)} | 184 | onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)} |
| 176 | onPlay={() => setPlaybackSource({ src, revision: sourceRevision })} | 185 | onPlay={() => setPlaybackSource({ src, revision: sourceRevision })} |
| ... | @@ -184,11 +193,23 @@ export function MediaVideoPlayer({ | ... | @@ -184,11 +193,23 @@ export function MediaVideoPlayer({ |
| 184 | ) : ( | 193 | ) : ( |
| 185 | <span | 194 | <span |
| 186 | role="status" | 195 | role="status" |
| 187 | aria-label="Loading video" | 196 | aria-label={failed ? "Video preview unavailable" : "Loading video"} |
| 188 | className={cn("block aspect-video w-full rounded-lg bg-muted/60", stateClassName)} | 197 | className={cn("block aspect-video w-full rounded-lg bg-muted/60", stateClassName)} |
| 189 | style={style} | 198 | style={style} |
| 190 | /> | 199 | /> |
| 191 | )} | 200 | )} |
| | 201 | {onOpen ? ( |
| | 202 | <button |
| | 203 | type="button" |
| | 204 | aria-label={label ? `Play ${label}` : "Play video"} |
| | 205 | onClick={onOpen} |
| | 206 | 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" |
| | 207 | > |
| | 208 | <span className="flex size-8 items-center justify-center rounded-full bg-black/50 text-white"> |
| | 209 | <PlayIcon aria-hidden className="size-4 fill-current" /> |
| | 210 | </span> |
| | 211 | </button> |
| | 212 | ) : null} |
| 192 | </span> | 213 | </span> |
| 193 | ); | 214 | ); |
| 194 | return actionsSource ? <MediaActions source={actionsSource}>{player}</MediaActions> : player; | 215 | return actionsSource ? <MediaActions source={actionsSource}>{player}</MediaActions> : player; |