diff --git a/public/dev/landscape.mp4 b/public/dev/landscape.mp4 new file mode 100644 index 0000000..abc4d49 Binary files /dev/null and b/public/dev/landscape.mp4 differ diff --git a/public/dev/portrait.mp4 b/public/dev/portrait.mp4 new file mode 100644 index 0000000..c6f26df Binary files /dev/null and b/public/dev/portrait.mp4 differ diff --git a/src/components/MobileFeed.tsx b/src/components/MobileFeed.tsx index 74e7a78..6db478d 100644 --- a/src/components/MobileFeed.tsx +++ b/src/components/MobileFeed.tsx @@ -76,18 +76,53 @@ function setRef(ref: Ref | 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(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 ( -
+
+ {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 + setThumbSize([e.currentTarget.naturalWidth, e.currentTarget.naturalHeight])} + /> + ) : null}
diff --git a/src/dev/FeedPreview.tsx b/src/dev/FeedPreview.tsx index 2390611..a642f3b 100644 --- a/src/dev/FeedPreview.tsx +++ b/src/dev/FeedPreview.tsx @@ -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(() => toCardPosts(FIXTURE_FEED_ITEMS)); + const [posts, setPosts] = useState(() => fixtureCards(FIXTURE_FEED_ITEMS)); const [draft, setDraft] = useState(""); const [file, setFile] = useState(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); }} diff --git a/src/dev/fixtures.ts b/src/dev/fixtures.ts index 5763bfa..45364dc 100644 --- a/src/dev/fixtures.ts +++ b/src/dev/fixtures.ts @@ -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 = - `` + + `` + `` + `` + - `` + + `` + (label - ? `${label}` + ? `${label}` : "") + ``; 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 = { + [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 = { 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", diff --git a/src/styles/mobile.css b/src/styles/mobile.css index cb905f7..faa747f 100644 --- a/src/styles/mobile.css +++ b/src/styles/mobile.css @@ -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; } diff --git a/tests/mobile-feed.test.ts b/tests/mobile-feed.test.ts index 618ffae..3ed85c7 100644 --- a/tests/mobile-feed.test.ts +++ b/tests/mobile-feed.test.ts @@ -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"); + }); +});