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
Binary file not shown.
Binary file not shown.
+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>
+5 -4
View File
@@ -9,19 +9,20 @@ import { DiscoverRailsView } from "@/components/DiscoverRails";
import { ExpertSearchView } from "@/components/ExpertSearch";
import { FeedView } from "@/components/FeedView";
import { toComments } from "@/lib/comment-model";
import { toCardPosts, type CardPost } from "@/lib/feed-model";
import { type CardPost } from "@/lib/feed-model";
import {
FIXTURE_CATEGORIES,
FIXTURE_COMMENTS,
FIXTURE_DISCOVER,
FIXTURE_FEED_ITEMS,
fixtureCards,
FIXTURE_NOW,
FIXTURE_PERSON,
FIXTURE_SEARCH_RESULTS,
} from "./fixtures";
export function FeedPreview() {
const [posts, setPosts] = useState<CardPost[]>(() => toCardPosts(FIXTURE_FEED_ITEMS));
const [posts, setPosts] = useState<CardPost[]>(() => fixtureCards(FIXTURE_FEED_ITEMS));
const [draft, setDraft] = useState("");
const [file, setFile] = useState<File | null>(null);
const [query, setQuery] = useState("");
@@ -55,7 +56,7 @@ export function FeedPreview() {
const it = raw as { post: { id: string } };
return { ...it, post: { ...it.post, id: `${it.post.id}-page${next}` } };
});
setPosts((cur) => [...cur, ...toCardPosts(items)]);
setPosts((cur) => [...cur, ...fixtureCards(items)]);
setPage(next);
setPaging(false);
}, 900);
@@ -152,7 +153,7 @@ export function FeedPreview() {
list.current?.scrollTo({ top: 0, behavior: "smooth" });
setRefreshing(true);
window.setTimeout(() => {
setPosts(toCardPosts(FIXTURE_FEED_ITEMS));
setPosts(fixtureCards(FIXTURE_FEED_ITEMS));
setRefreshing(false);
}, 600);
}}
+46 -5
View File
@@ -2,21 +2,22 @@
// real: no Minnect person, post, token or URL. Images are inline SVG data URIs
// so the preview never calls the network. Later tasks extend this file.
import type { Person } from "@/lib/auth-client";
import { toCardPosts, type CardPost } from "@/lib/feed-model";
import type { DiscoverCategory, DiscoverData, DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
import type { ExpertResult } from "@/lib/experts-search";
/** Fixed "now" so relative times in the preview are stable. */
export const FIXTURE_NOW = Date.parse("2026-10-19T12:00:00Z");
/** A 16:9 gradient placeholder image, drawn with hsl() so no fetch happens. */
export function placeholderImage(hue: number, label = ""): string {
/** A gradient placeholder image (16:9 unless a size is given), drawn with hsl() so no fetch happens. */
export function placeholderImage(hue: number, label = "", width = 1280, height = 720): string {
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">` +
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
`<stop offset="0" stop-color="hsl(${hue} 45% 32%)"/><stop offset="1" stop-color="hsl(${(hue + 40) % 360} 40% 14%)"/>` +
`</linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/>` +
`</linearGradient></defs><rect width="${width}" height="${height}" fill="url(#g)"/>` +
(label
? `<text x="64" y="660" font-family="system-ui,sans-serif" font-size="56" font-weight="700" fill="hsl(0 0% 100% / .85)">${label}</text>`
? `<text x="64" y="${height - 60}" font-family="system-ui,sans-serif" font-size="56" font-weight="700" fill="hsl(0 0% 100% / .85)">${label}</text>`
: "") +
`</svg>`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
@@ -25,6 +26,22 @@ export function placeholderImage(hue: number, label = ""): string {
/** Not a real file: preload is off, so the preview video shows only its poster, play button and length. */
const FIXTURE_VIDEO_URL = "https://cdn.feed.sharedservices.vt.com/dev-fixture.mp4";
/** Fixture videos with real (tiny) files in public/dev: the feed only accepts CDN-host mp4 URLs, so these are swapped to local paths after parsing. */
const FIXTURE_LANDSCAPE_URL = "https://cdn.feed.sharedservices.vt.com/dev-landscape.mp4";
const FIXTURE_PORTRAIT_URL = "https://cdn.feed.sharedservices.vt.com/dev-portrait.mp4";
const LOCAL_FIXTURE_VIDEOS: Record<string, string> = {
[FIXTURE_LANDSCAPE_URL]: "/dev/landscape.mp4",
[FIXTURE_PORTRAIT_URL]: "/dev/portrait.mp4",
};
/** toCardPosts for the preview, with the fixture video URLs pointed at the local files. */
export function fixtureCards(items: unknown[]): CardPost[] {
return toCardPosts(items).map((p) => ({
...p,
media: p.media.map((m) => (m.mp4Url && LOCAL_FIXTURE_VIDEOS[m.mp4Url] ? { ...m, mp4Url: LOCAL_FIXTURE_VIDEOS[m.mp4Url] } : m)),
}));
}
/** Comments for the preview, in the shape /api/posts/[id]/comments returns. p2 holds a removed one; p4 has none. */
export const FIXTURE_COMMENTS: Record<string, unknown[]> = {
p1: [{ id: "c1", content: "Congratulations, the flow reads well.", createdAt: "2026-10-19T11:50:00Z", author: { displayName: "Priya Natarajan" } }],
@@ -89,6 +106,30 @@ export const FIXTURE_FEED_ITEMS: unknown[] = [
mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(210, "Weekly reviews"), mp4Url: FIXTURE_VIDEO_URL, videoDuration: 154, order: 0 }],
},
},
{
type: "post",
publishedAt: "2026-10-19T07:50:00Z",
likesCount: 31,
commentsCount: 4,
post: {
id: "p8",
content: "Landscape recording of the quarterly walkthrough. It should show whole, centered, over a blurred copy of its own thumbnail.",
author: author("a8", "Tomas Reyes"),
mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(180, "Landscape 16:9"), mp4Url: FIXTURE_LANDSCAPE_URL, videoDuration: 2, order: 0 }],
},
},
{
type: "post",
publishedAt: "2026-10-19T07:20:00Z",
likesCount: 44,
commentsCount: 5,
post: {
id: "p9",
content: "Portrait clip shot on a phone. It should fill the whole slide.",
author: author("a9", "Ines Moreau"),
mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(300, "Portrait 9:16", 720, 1280), mp4Url: FIXTURE_PORTRAIT_URL, videoDuration: 2, order: 0 }],
},
},
{
type: "post",
publishedAt: "2026-10-18T21:20:00Z",
+16
View File
@@ -52,6 +52,22 @@
.slide-video .slide-art video,
.slide-video .slide-art img { display: block; width: 100%; height: 100%; object-fit: cover; }
.slide-video .slide-art > div { height: 100%; }
.slide-video .slide-art video { background: transparent; }
/* Wider than 4:5: show the whole video, centered, over a blurred, darkened, enlarged copy of its own thumbnail. */
.slide-backdrop { display: none; }
.slide-video[data-fit="contain"] .slide-backdrop {
display: block;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
transform: scale(1.25);
filter: blur(28px) brightness(0.5);
pointer-events: none;
}
.slide-video[data-fit="contain"] .slide-art video,
.slide-video[data-fit="contain"] .slide-art img { object-fit: contain; }
.slide-pending { display: grid; place-items: center; color: var(--ink-2); font-size: 14px; }
.slide-play { z-index: 4; }
.slide-dur { top: 128px; right: 16px; z-index: 4; }
+11
View File
@@ -195,3 +195,14 @@ describe("top bar and progress rail", () => {
expect(css).toMatch(/\.ph-top \{[^}]*safe-area-inset-top/);
});
});
describe("videoFit", () => {
it("fills the slide for 4:5 and taller, shows whole for wider, covers while unknown", async () => {
const { videoFit } = await import("@/components/MobileFeed");
expect(videoFit(720, 1280)).toBe("cover");
expect(videoFit(1080, 1350)).toBe("cover");
expect(videoFit(1080, 1080)).toBe("contain");
expect(videoFit(1920, 1080)).toBe("contain");
expect(videoFit(0, 0)).toBe("cover");
});
});