9uv0f8oll: Fit mobile videos to the screen without cropping

This commit is contained in:
2026-10-20 09:59:38 -06:00
parent c4434198ad
commit d653e8e75d
7 changed files with 114 additions and 10 deletions
+36 -1
View File
@@ -76,18 +76,53 @@ function setRef<T>(ref: Ref<T> | undefined, value: T | null) {
else if (ref) (ref as { current: T | null }).current = value;
}
/** Aspect ratio (width / height) at or below which a video fills the slide (4:5 or taller). */
export const COVER_MAX_RATIO = 0.8;
/** "cover" for portrait and near-portrait video, "contain" for wider, "cover" while the size is unknown. */
export function videoFit(width: number, height: number): "cover" | "contain" {
if (!(width > 0) || !(height > 0)) return "cover";
return width / height <= COVER_MAX_RATIO ? "cover" : "contain";
}
function SlideMedia({ media, author }: { media: CardMedia; author: string }) {
const box = useRef<HTMLDivElement>(null);
const [started, setStarted] = useState(false);
// Real video size once metadata loads; until then the thumbnail's size decides.
const [videoSize, setVideoSize] = useState<[number, number] | null>(null);
const [thumbSize, setThumbSize] = useState<[number, number] | null>(null);
const playable = isPlayableVideo(media);
useEffect(() => {
const video = box.current?.querySelector("video");
if (!video) return;
const read = () => {
if (video.videoWidth > 0 && video.videoHeight > 0) setVideoSize([video.videoWidth, video.videoHeight]);
};
read();
video.addEventListener("loadedmetadata", read);
return () => video.removeEventListener("loadedmetadata", read);
}, [playable, media.mp4Url, media.hlsUrl]);
if (playable) {
const size = videoSize ?? thumbSize;
const fit = size ? videoFit(size[0], size[1]) : "cover";
const duration = formatDuration(media.durationSeconds);
const start = () => {
setStarted(true);
void box.current?.querySelector("video")?.play().catch(() => {});
};
return (
<div className="slide-media slide-video" ref={box}>
<div className="slide-media slide-video" data-fit={fit} ref={box}>
{media.src ? (
// The thumbnail doubles as the blurred backdrop behind a contained video, and as the size fallback.
// eslint-disable-next-line @next/next/no-img-element
<img
className="slide-backdrop"
src={media.src}
alt=""
aria-hidden="true"
onLoad={(e) => setThumbSize([e.currentTarget.naturalWidth, e.currentTarget.naturalHeight])}
/>
) : null}
<div className="slide-art">
<VideoPlayer mp4Url={media.mp4Url} hlsUrl={media.hlsUrl} poster={media.src} label={`Video from ${author}`} controls={started} />
</div>