Claude 17d285f87e
fix(web): size uploaded videos from their own frame
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
2026-10-03 14:26:11 +00:00

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;
}