Feed browser: Mobile video and caption fixes #6
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;
|
||||
}
|
||||
|
||||
/** 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>
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user