diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx
index 6616418..fb87ac7 100644
--- a/src/components/PostCard.tsx
+++ b/src/components/PostCard.tsx
@@ -87,9 +87,9 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne
{deleteError ?
Could not delete the post.
: null}
{post.text ? {post.text}
: null}
{post.media.map((m, i) =>
- m.kind === "video" && m.hlsUrl ? (
+ m.kind === "video" && (m.mp4Url || m.hlsUrl) ? (
-
+
) : m.src ? (
diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx
index dfb5f63..8c2c73e 100644
--- a/src/components/VideoPlayer.tsx
+++ b/src/components/VideoPlayer.tsx
@@ -4,7 +4,8 @@ import { useEffect, useRef, useState } from "react";
import { choosePlayback, supportsNativeHls } from "@/lib/video-playback";
export interface VideoPlayerProps {
- hlsUrl: string;
+ mp4Url?: string | null;
+ hlsUrl?: string | null;
poster: string | null;
label: string;
}
@@ -12,17 +13,19 @@ export interface VideoPlayerProps {
const UNPLAYABLE = "Video can't be played here.";
/**
- * Real
);
}
+ if (!mp4Url && !hlsUrl) {
+ return poster ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : null;
+ }
return (
p.media.some((m) => m.kind === "video" && m.hlsUrl);
+/** A video is playable once it has an mp4Url or an hlsUrl. */
+export const isPlayableVideo = (m: CardMedia) => m.kind === "video" && !!(m.mp4Url || m.hlsUrl);
+const hasPlayable = (p: CardPost) => p.media.some(isPlayableVideo);
/**
- * Swap in the media of shown posts whose video gained an hlsUrl on a refresh
+ * Swap in the media of shown posts whose video gained an mp4Url or hlsUrl on a refresh
* (transcoding finished), so the thumbnail upgrades to the player in place.
* Returns the same array when nothing changed.
*/
@@ -155,7 +172,7 @@ export function upgradeVideos(current: CardPost[], fresh: CardPost[]): CardPost[
let changed = false;
const next = current.map((p) => {
const f = byKey.get(p.key);
- if (f && !hasHls(p) && hasHls(f)) {
+ if (f && !hasPlayable(p) && hasPlayable(f)) {
changed = true;
return { ...p, media: f.media };
}
diff --git a/tests/feed-view.test.ts b/tests/feed-view.test.ts
index 237c62f..48bcfbb 100644
--- a/tests/feed-view.test.ts
+++ b/tests/feed-view.test.ts
@@ -144,7 +144,7 @@ describe("video transcoding", () => {
it("keeps a just-published video as a thumbnail with no hlsUrl", () => {
const [p] = vid(null);
- expect(p.media).toEqual([{ kind: "video", src: "https://i/t.jpg", hlsUrl: null }]);
+ expect(p.media).toEqual([{ kind: "video", src: "https://i/t.jpg", hlsUrl: null, mp4Url: null }]);
});
it("keeps a video with neither thumbnail nor hlsUrl as a pending card", () => {
@@ -161,6 +161,22 @@ describe("video transcoding", () => {
expect(upgradeVideos(shown, vid(null))).toBe(shown);
});
+ it("captures mp4Url on the feed CDN and rejects other hosts", () => {
+ const mk = (u: unknown) =>
+ toCardPosts([post("9", "2026-10-19T10:00:00Z", { mediaItems: [{ mediaType: "video", thumbnailUrl: "https://i/t.jpg", mp4Url: u }] })])[0].media[0];
+ expect(mk("https://cdn.feed.sharedservices.vt.com/v.mp4").mp4Url).toBe("https://cdn.feed.sharedservices.vt.com/v.mp4");
+ expect(mk("https://evil.example/v.mp4").mp4Url).toBeNull();
+ expect(mk("http://cdn.feed.sharedservices.vt.com/v.mp4").mp4Url).toBeNull();
+ });
+
+ it("upgrades the thumbnail in place when only mp4Url appears", () => {
+ const withMp4 = toCardPosts([post("7", "2026-10-19T10:00:00Z", { mediaItems: [{ mediaType: "video", thumbnailUrl: "https://i/t.jpg", mp4Url: "https://cdn.feed.sharedservices.vt.com/v.mp4" }] })]);
+ const shown = vid(null);
+ const next = upgradeVideos(shown, withMp4);
+ expect(next[0].media[0].mp4Url).toContain("cdn.feed");
+ expect(upgradeVideos(next, vid(null))).toBe(next);
+ });
+
it("renders the thumbnail while transcoding and the player once hlsUrl is present", () => {
const html = render({ posts: vid(null) });
expect(html).toContain("https://i/t.jpg");
diff --git a/tests/video-playback.test.ts b/tests/video-playback.test.ts
index 6a96356..092f8fb 100644
--- a/tests/video-playback.test.ts
+++ b/tests/video-playback.test.ts
@@ -5,6 +5,7 @@ import { PostCard } from "@/components/PostCard";
import { toCardPosts } from "@/lib/feed-model";
import { choosePlayback, supportsNativeHls } from "@/lib/video-playback";
+const MP4 = "https://cdn.feed.sharedservices.vt.com/v.mp4";
const HLS = "https://stream.mux.com/abc.m3u8";
const item = (media: Record) => ({
type: "post",
@@ -55,4 +56,24 @@ describe("video media in the card", () => {
expect(out).not.toContain(" {
+ const out = html({ ...video, hlsUrl: HLS, mp4Url: MP4 });
+ expect(out).toContain(" {
+ expect(html({ ...video, mp4Url: MP4 })).toContain(`src="${MP4}"`);
+ });
+ it("hls-only video renders a video without a src (hls.js attaches it)", () => {
+ const out = html({ ...video, hlsUrl: HLS });
+ expect(out).toContain(" {
+ const out = html({ ...video, mp4Url: "https://evil.example/v.mp4" });
+ expect(out).not.toContain("