9uv0f8oll: Fit mobile videos to the screen without cropping
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -76,18 +76,53 @@ function setRef<T>(ref: Ref<T> | undefined, value: T | null) {
|
|||||||
else if (ref) (ref as { current: T | null }).current = value;
|
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 }) {
|
function SlideMedia({ media, author }: { media: CardMedia; author: string }) {
|
||||||
const box = useRef<HTMLDivElement>(null);
|
const box = useRef<HTMLDivElement>(null);
|
||||||
const [started, setStarted] = useState(false);
|
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);
|
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) {
|
if (playable) {
|
||||||
|
const size = videoSize ?? thumbSize;
|
||||||
|
const fit = size ? videoFit(size[0], size[1]) : "cover";
|
||||||
const duration = formatDuration(media.durationSeconds);
|
const duration = formatDuration(media.durationSeconds);
|
||||||
const start = () => {
|
const start = () => {
|
||||||
setStarted(true);
|
setStarted(true);
|
||||||
void box.current?.querySelector("video")?.play().catch(() => {});
|
void box.current?.querySelector("video")?.play().catch(() => {});
|
||||||
};
|
};
|
||||||
return (
|
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">
|
<div className="slide-art">
|
||||||
<VideoPlayer mp4Url={media.mp4Url} hlsUrl={media.hlsUrl} poster={media.src} label={`Video from ${author}`} controls={started} />
|
<VideoPlayer mp4Url={media.mp4Url} hlsUrl={media.hlsUrl} poster={media.src} label={`Video from ${author}`} controls={started} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,19 +9,20 @@ import { DiscoverRailsView } from "@/components/DiscoverRails";
|
|||||||
import { ExpertSearchView } from "@/components/ExpertSearch";
|
import { ExpertSearchView } from "@/components/ExpertSearch";
|
||||||
import { FeedView } from "@/components/FeedView";
|
import { FeedView } from "@/components/FeedView";
|
||||||
import { toComments } from "@/lib/comment-model";
|
import { toComments } from "@/lib/comment-model";
|
||||||
import { toCardPosts, type CardPost } from "@/lib/feed-model";
|
import { type CardPost } from "@/lib/feed-model";
|
||||||
import {
|
import {
|
||||||
FIXTURE_CATEGORIES,
|
FIXTURE_CATEGORIES,
|
||||||
FIXTURE_COMMENTS,
|
FIXTURE_COMMENTS,
|
||||||
FIXTURE_DISCOVER,
|
FIXTURE_DISCOVER,
|
||||||
FIXTURE_FEED_ITEMS,
|
FIXTURE_FEED_ITEMS,
|
||||||
|
fixtureCards,
|
||||||
FIXTURE_NOW,
|
FIXTURE_NOW,
|
||||||
FIXTURE_PERSON,
|
FIXTURE_PERSON,
|
||||||
FIXTURE_SEARCH_RESULTS,
|
FIXTURE_SEARCH_RESULTS,
|
||||||
} from "./fixtures";
|
} from "./fixtures";
|
||||||
|
|
||||||
export function FeedPreview() {
|
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 [draft, setDraft] = useState("");
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@@ -55,7 +56,7 @@ export function FeedPreview() {
|
|||||||
const it = raw as { post: { id: string } };
|
const it = raw as { post: { id: string } };
|
||||||
return { ...it, post: { ...it.post, id: `${it.post.id}-page${next}` } };
|
return { ...it, post: { ...it.post, id: `${it.post.id}-page${next}` } };
|
||||||
});
|
});
|
||||||
setPosts((cur) => [...cur, ...toCardPosts(items)]);
|
setPosts((cur) => [...cur, ...fixtureCards(items)]);
|
||||||
setPage(next);
|
setPage(next);
|
||||||
setPaging(false);
|
setPaging(false);
|
||||||
}, 900);
|
}, 900);
|
||||||
@@ -152,7 +153,7 @@ export function FeedPreview() {
|
|||||||
list.current?.scrollTo({ top: 0, behavior: "smooth" });
|
list.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
setPosts(toCardPosts(FIXTURE_FEED_ITEMS));
|
setPosts(fixtureCards(FIXTURE_FEED_ITEMS));
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}, 600);
|
}, 600);
|
||||||
}}
|
}}
|
||||||
|
|||||||
+46
-5
@@ -2,21 +2,22 @@
|
|||||||
// real: no Minnect person, post, token or URL. Images are inline SVG data URIs
|
// 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.
|
// so the preview never calls the network. Later tasks extend this file.
|
||||||
import type { Person } from "@/lib/auth-client";
|
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 { DiscoverCategory, DiscoverData, DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
|
||||||
import type { ExpertResult } from "@/lib/experts-search";
|
import type { ExpertResult } from "@/lib/experts-search";
|
||||||
|
|
||||||
/** Fixed "now" so relative times in the preview are stable. */
|
/** Fixed "now" so relative times in the preview are stable. */
|
||||||
export const FIXTURE_NOW = Date.parse("2026-10-19T12:00:00Z");
|
export const FIXTURE_NOW = Date.parse("2026-10-19T12:00:00Z");
|
||||||
|
|
||||||
/** A 16:9 gradient placeholder image, drawn with hsl() so no fetch happens. */
|
/** A gradient placeholder image (16:9 unless a size is given), drawn with hsl() so no fetch happens. */
|
||||||
export function placeholderImage(hue: number, label = ""): string {
|
export function placeholderImage(hue: number, label = "", width = 1280, height = 720): string {
|
||||||
const svg =
|
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">` +
|
`<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%)"/>` +
|
`<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
|
(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>`;
|
`</svg>`;
|
||||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(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. */
|
/** 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";
|
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. */
|
/** 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[]> = {
|
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" } }],
|
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 }],
|
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",
|
type: "post",
|
||||||
publishedAt: "2026-10-18T21:20:00Z",
|
publishedAt: "2026-10-18T21:20:00Z",
|
||||||
|
|||||||
@@ -52,6 +52,22 @@
|
|||||||
.slide-video .slide-art video,
|
.slide-video .slide-art video,
|
||||||
.slide-video .slide-art img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
.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 > 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-pending { display: grid; place-items: center; color: var(--ink-2); font-size: 14px; }
|
||||||
.slide-play { z-index: 4; }
|
.slide-play { z-index: 4; }
|
||||||
.slide-dur { top: 128px; right: 16px; z-index: 4; }
|
.slide-dur { top: 128px; right: 16px; z-index: 4; }
|
||||||
|
|||||||
@@ -195,3 +195,14 @@ describe("top bar and progress rail", () => {
|
|||||||
expect(css).toMatch(/\.ph-top \{[^}]*safe-area-inset-top/);
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user