Uploaded videos carry no dimensions, so a vertical clip sat letterboxed in a 16:9 frame. The player now takes the frame size from the file's metadata when the block has none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
130 lines
4.1 KiB
TypeScript
130 lines
4.1 KiB
TypeScript
import { useLingui } from "@lingui/react/macro";
|
|
import { buttonVariants } from "@rakazo/ui-web";
|
|
import { Download } from "lucide-react";
|
|
import { type RefObject, useEffect, useRef, useState } from "react";
|
|
import { artifactUrl } from "../../lib/artifact-url";
|
|
|
|
export type MediaPlayerProps = {
|
|
artifactId: string;
|
|
kind: "video" | "audio";
|
|
name: string;
|
|
posterArtifactId?: string;
|
|
width?: number;
|
|
height?: number;
|
|
};
|
|
|
|
const MAX_VIDEO_WIDTH = 420;
|
|
const MAX_VIDEO_HEIGHT = 480;
|
|
|
|
/** Plays a video or audio artifact in place; loads nothing until it is near the viewport. */
|
|
export function MediaPlayer({
|
|
artifactId,
|
|
kind,
|
|
name,
|
|
posterArtifactId,
|
|
width,
|
|
height,
|
|
}: MediaPlayerProps) {
|
|
const container = useRef<HTMLDivElement>(null);
|
|
const near = useNearViewport(container);
|
|
const [failed, setFailed] = useState(false);
|
|
const [frame, setFrame] = useState<{ width: number; height: number } | null>(null);
|
|
const src = near ? artifactUrl(artifactId) : undefined;
|
|
|
|
if (failed) return <MediaFallback artifactId={artifactId} name={name} />;
|
|
|
|
if (kind === "audio") {
|
|
return (
|
|
<div ref={container} className="w-72 max-w-full" data-testid="media-audio">
|
|
{/* biome-ignore lint/a11y/useMediaCaption: user media has no caption track. */}
|
|
<audio
|
|
src={src}
|
|
controls
|
|
preload="metadata"
|
|
aria-label={name}
|
|
className="w-full"
|
|
onError={() => setFailed(true)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Uploads carry no dimensions; size the frame from the file once its metadata loads.
|
|
const box = videoBox(width ?? frame?.width, height ?? frame?.height);
|
|
return (
|
|
<div
|
|
ref={container}
|
|
className="max-w-full overflow-hidden rounded-[20px] bg-muted"
|
|
style={{ width: box.width, aspectRatio: `${box.width} / ${box.height}` }}
|
|
data-testid="media-video"
|
|
>
|
|
{/* biome-ignore lint/a11y/useMediaCaption: user media has no caption track. */}
|
|
<video
|
|
src={src}
|
|
poster={near && posterArtifactId ? artifactUrl(posterArtifactId) : undefined}
|
|
controls
|
|
playsInline
|
|
preload="metadata"
|
|
aria-label={name}
|
|
className="block size-full object-contain"
|
|
onError={() => setFailed(true)}
|
|
onLoadedMetadata={(event) => {
|
|
const { videoWidth, videoHeight } = event.currentTarget;
|
|
if (videoWidth > 0 && videoHeight > 0) {
|
|
setFrame({ width: videoWidth, height: videoHeight });
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Fits the frame into the chat column, defaulting to 16:9 until the size is known. */
|
|
function videoBox(width?: number, height?: number) {
|
|
const w = width && width > 0 ? width : 16;
|
|
const h = height && height > 0 ? height : 9;
|
|
const scale = Math.min(MAX_VIDEO_WIDTH / w, MAX_VIDEO_HEIGHT / h);
|
|
return { width: Math.round(w * scale), height: Math.round(h * scale) };
|
|
}
|
|
|
|
function MediaFallback({ artifactId, name }: { artifactId: string; name: string }) {
|
|
const { t } = useLingui();
|
|
return (
|
|
<div className="flex max-w-full items-center gap-2 rounded-[20px] border border-border bg-muted py-2 ps-4 pe-2 text-ui-body text-muted-foreground">
|
|
<span className="min-w-0 truncate">{name}</span>
|
|
<a
|
|
href={artifactUrl(artifactId, { download: true })}
|
|
download={name}
|
|
aria-label={t`Download ${name}`}
|
|
title={t`Download ${name}`}
|
|
className={buttonVariants({ variant: "ghost", size: "icon-sm" })}
|
|
>
|
|
<Download />
|
|
</a>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function useNearViewport(ref: RefObject<HTMLElement | null>) {
|
|
const [near, setNear] = useState(false);
|
|
useEffect(() => {
|
|
const element = ref.current;
|
|
if (!element || typeof IntersectionObserver === "undefined") {
|
|
setNear(true);
|
|
return;
|
|
}
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries.some((entry) => entry.isIntersecting)) {
|
|
setNear(true);
|
|
observer.disconnect();
|
|
}
|
|
},
|
|
{ rootMargin: "320px" },
|
|
);
|
|
observer.observe(element);
|
|
return () => observer.disconnect();
|
|
}, [ref]);
|
|
return near;
|
|
}
|