Merge video mp4Url playback fix into develop
Play feed videos from the direct mp4Url (Minnect CDN) instead of the tokenless Mux hlsUrl that 404s; hls.js and thumbnail remain fallbacks. Task qhkr2z22o.
This commit is contained in:
@@ -87,9 +87,9 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne
|
|||||||
{deleteError ? <p className="like-error" role="alert">Could not delete the post.</p> : null}
|
{deleteError ? <p className="like-error" role="alert">Could not delete the post.</p> : null}
|
||||||
{post.text ? <p className="post-text">{post.text}</p> : null}
|
{post.text ? <p className="post-text">{post.text}</p> : null}
|
||||||
{post.media.map((m, i) =>
|
{post.media.map((m, i) =>
|
||||||
m.kind === "video" && m.hlsUrl ? (
|
m.kind === "video" && (m.mp4Url || m.hlsUrl) ? (
|
||||||
<div className="post-media post-video" key={i}>
|
<div className="post-media post-video" key={i}>
|
||||||
<VideoPlayer hlsUrl={m.hlsUrl} poster={m.src} label={`Video from ${post.authorName}`} />
|
<VideoPlayer mp4Url={m.mp4Url} hlsUrl={m.hlsUrl} poster={m.src} label={`Video from ${post.authorName}`} />
|
||||||
</div>
|
</div>
|
||||||
) : m.src ? (
|
) : m.src ? (
|
||||||
<div className={m.kind === "video" ? "post-media post-video" : "post-media"} key={i}>
|
<div className={m.kind === "video" ? "post-media post-video" : "post-media"} key={i}>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { choosePlayback, supportsNativeHls } from "@/lib/video-playback";
|
import { choosePlayback, supportsNativeHls } from "@/lib/video-playback";
|
||||||
|
|
||||||
export interface VideoPlayerProps {
|
export interface VideoPlayerProps {
|
||||||
hlsUrl: string;
|
mp4Url?: string | null;
|
||||||
|
hlsUrl?: string | null;
|
||||||
poster: string | null;
|
poster: string | null;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
@@ -12,17 +13,19 @@ export interface VideoPlayerProps {
|
|||||||
const UNPLAYABLE = "Video can't be played here.";
|
const UNPLAYABLE = "Video can't be played here.";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Real <video controls>. hls.js (loaded only when a video card mounts) is
|
* Real <video controls>. Order: direct mp4Url (plain progressive source), else
|
||||||
* preferred; native HLS is used only where hls.js is unsupported. Never
|
* hls.js (lazy-loaded, only for the HLS path; native HLS only where hls.js is
|
||||||
|
* unsupported), else thumbnail only. Never
|
||||||
* autoplays. Any failure falls back to the thumbnail with a short note.
|
* autoplays. Any failure falls back to the thumbnail with a short note.
|
||||||
*/
|
*/
|
||||||
export function VideoPlayer({ hlsUrl, poster, label }: VideoPlayerProps) {
|
export function VideoPlayer({ mp4Url, hlsUrl, poster, label }: VideoPlayerProps) {
|
||||||
const ref = useRef<HTMLVideoElement>(null);
|
const ref = useRef<HTMLVideoElement>(null);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = ref.current;
|
const video = ref.current;
|
||||||
if (!video) return;
|
// A progressive MP4 needs no hls.js; only the HLS fallback loads it.
|
||||||
|
if (!video || mp4Url || !hlsUrl) return;
|
||||||
let destroy: (() => void) | undefined;
|
let destroy: (() => void) | undefined;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
import("hls.js")
|
import("hls.js")
|
||||||
@@ -51,7 +54,7 @@ export function VideoPlayer({ hlsUrl, poster, label }: VideoPlayerProps) {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
destroy?.();
|
destroy?.();
|
||||||
};
|
};
|
||||||
}, [hlsUrl]);
|
}, [mp4Url, hlsUrl]);
|
||||||
|
|
||||||
if (failed) {
|
if (failed) {
|
||||||
return (
|
return (
|
||||||
@@ -64,9 +67,16 @@ export function VideoPlayer({ hlsUrl, poster, label }: VideoPlayerProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!mp4Url && !hlsUrl) {
|
||||||
|
return poster ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={poster} alt="Video thumbnail" loading="lazy" />
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<video
|
<video
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
src={mp4Url ?? undefined}
|
||||||
controls
|
controls
|
||||||
playsInline
|
playsInline
|
||||||
preload="none"
|
preload="none"
|
||||||
|
|||||||
+22
-5
@@ -7,6 +7,8 @@ export interface CardMedia {
|
|||||||
src: string | null;
|
src: string | null;
|
||||||
/** Mux HLS playlist (videos only). Null when the video is not ready yet. */
|
/** Mux HLS playlist (videos only). Null when the video is not ready yet. */
|
||||||
hlsUrl?: string | null;
|
hlsUrl?: string | null;
|
||||||
|
/** Direct progressive MP4 on the feed CDN (videos only). Preferred for playback. */
|
||||||
|
mp4Url?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CardPost {
|
export interface CardPost {
|
||||||
@@ -65,6 +67,18 @@ export function muxHlsUrl(v: unknown): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The MP4 URL only when it is https on the feed CDN. */
|
||||||
|
export function feedMp4Url(v: unknown): string | null {
|
||||||
|
const s = str(v);
|
||||||
|
if (!s) return null;
|
||||||
|
try {
|
||||||
|
const u = new URL(s);
|
||||||
|
return u.protocol === "https:" && u.hostname === "cdn.feed.sharedservices.vt.com" ? s : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toMedia(raw: unknown): CardMedia[] {
|
function toMedia(raw: unknown): CardMedia[] {
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
const items = raw
|
const items = raw
|
||||||
@@ -76,8 +90,9 @@ function toMedia(raw: unknown): CardMedia[] {
|
|||||||
if (m.mediaType === "video") {
|
if (m.mediaType === "video") {
|
||||||
const thumb = str(m.thumbnailUrl);
|
const thumb = str(m.thumbnailUrl);
|
||||||
const hls = muxHlsUrl(m.hlsUrl);
|
const hls = muxHlsUrl(m.hlsUrl);
|
||||||
// Keep a video even with no thumbnail or hlsUrl yet (just published).
|
const mp4 = feedMp4Url(m.mp4Url);
|
||||||
out.push({ kind: "video", src: thumb, hlsUrl: hls });
|
// Keep a video even with no thumbnail or playable URL yet (just published).
|
||||||
|
out.push({ kind: "video", src: thumb, hlsUrl: hls, mp4Url: mp4 });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const src = str(m.imageMediumWebpUrl) ?? str(m.imageMediumUrl) ?? str(m.imageUrl);
|
const src = str(m.imageMediumWebpUrl) ?? str(m.imageMediumUrl) ?? str(m.imageUrl);
|
||||||
@@ -143,10 +158,12 @@ export function mergePosts(...lists: CardPost[][]): CardPost[] {
|
|||||||
return sortByPublishedDesc(all);
|
return sortByPublishedDesc(all);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasHls = (p: CardPost) => 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.
|
* (transcoding finished), so the thumbnail upgrades to the player in place.
|
||||||
* Returns the same array when nothing changed.
|
* Returns the same array when nothing changed.
|
||||||
*/
|
*/
|
||||||
@@ -155,7 +172,7 @@ export function upgradeVideos(current: CardPost[], fresh: CardPost[]): CardPost[
|
|||||||
let changed = false;
|
let changed = false;
|
||||||
const next = current.map((p) => {
|
const next = current.map((p) => {
|
||||||
const f = byKey.get(p.key);
|
const f = byKey.get(p.key);
|
||||||
if (f && !hasHls(p) && hasHls(f)) {
|
if (f && !hasPlayable(p) && hasPlayable(f)) {
|
||||||
changed = true;
|
changed = true;
|
||||||
return { ...p, media: f.media };
|
return { ...p, media: f.media };
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-1
@@ -144,7 +144,7 @@ describe("video transcoding", () => {
|
|||||||
|
|
||||||
it("keeps a just-published video as a thumbnail with no hlsUrl", () => {
|
it("keeps a just-published video as a thumbnail with no hlsUrl", () => {
|
||||||
const [p] = vid(null);
|
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", () => {
|
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);
|
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", () => {
|
it("renders the thumbnail while transcoding and the player once hlsUrl is present", () => {
|
||||||
const html = render({ posts: vid(null) });
|
const html = render({ posts: vid(null) });
|
||||||
expect(html).toContain("https://i/t.jpg");
|
expect(html).toContain("https://i/t.jpg");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { PostCard } from "@/components/PostCard";
|
|||||||
import { toCardPosts } from "@/lib/feed-model";
|
import { toCardPosts } from "@/lib/feed-model";
|
||||||
import { choosePlayback, supportsNativeHls } from "@/lib/video-playback";
|
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 HLS = "https://stream.mux.com/abc.m3u8";
|
||||||
const item = (media: Record<string, unknown>) => ({
|
const item = (media: Record<string, unknown>) => ({
|
||||||
type: "post",
|
type: "post",
|
||||||
@@ -55,4 +56,24 @@ describe("video media in the card", () => {
|
|||||||
expect(out).not.toContain("<video");
|
expect(out).not.toContain("<video");
|
||||||
expect(out).toContain('src="https://i/v.jpg"');
|
expect(out).toContain('src="https://i/v.jpg"');
|
||||||
});
|
});
|
||||||
|
it("prefers mp4Url: plain video source, no hls.js needed", () => {
|
||||||
|
const out = html({ ...video, hlsUrl: HLS, mp4Url: MP4 });
|
||||||
|
expect(out).toContain("<video");
|
||||||
|
expect(out).toContain(`src="${MP4}"`);
|
||||||
|
expect(out).toContain('poster="https://i/v.jpg"');
|
||||||
|
expect(out).not.toContain("m3u8");
|
||||||
|
expect(out).not.toContain("autoplay");
|
||||||
|
});
|
||||||
|
it("mp4Url alone renders the player", () => {
|
||||||
|
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("<video");
|
||||||
|
expect(out).not.toContain(`src="${HLS}"`);
|
||||||
|
});
|
||||||
|
it("rejects an mp4Url off the feed CDN", () => {
|
||||||
|
const out = html({ ...video, mp4Url: "https://evil.example/v.mp4" });
|
||||||
|
expect(out).not.toContain("<video");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user