vycxexra8: Play feed videos from hlsUrl with a thumbnail fallback

This commit is contained in:
2026-10-19 14:03:12 -06:00
parent 18e0386a99
commit 69e3c1ba2f
8 changed files with 178 additions and 2 deletions
+7
View File
@@ -8,6 +8,7 @@
"name": "frontend-helper",
"version": "0.1.0",
"dependencies": {
"hls.js": "1.7.3",
"next": "15.5.28",
"react": "19.0.0",
"react-dom": "19.0.0"
@@ -4321,6 +4322,12 @@
"node": ">= 0.4"
}
},
"node_modules/hls.js": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz",
"integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==",
"license": "Apache-2.0"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+1
View File
@@ -11,6 +11,7 @@
"test": "vitest run"
},
"dependencies": {
"hls.js": "1.7.3",
"next": "15.5.28",
"react": "19.0.0",
"react-dom": "19.0.0"
+2
View File
@@ -24,6 +24,8 @@ button:disabled { opacity: .5; cursor: not-allowed; }
.post-who time { color: var(--grey); font-size: 13px; }
.post-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.5; }
.post-media img { display: block; width: 100%; height: auto; border-radius: 8px; }
.post-video { background: #000; border-radius: 8px; overflow: hidden; }
.post-video video { display: block; width: 100%; max-height: 520px; background: #000; }
.post-stats { display: flex; gap: 16px; color: var(--grey); font-size: 14px; }
.heart { color: var(--red); }
.feed-message { text-align: center; color: var(--grey); padding: 32px 16px; }
+6 -1
View File
@@ -1,3 +1,4 @@
import { VideoPlayer } from "@/components/VideoPlayer";
import { initials, relativeTime, type CardPost } from "@/lib/feed-model";
export function PostCard({ post, now }: { post: CardPost; now?: number }) {
@@ -19,7 +20,11 @@ export function PostCard({ post, now }: { post: CardPost; now?: number }) {
</header>
{post.text ? <p className="post-text">{post.text}</p> : null}
{post.media.map((m, i) =>
m.src ? (
m.kind === "video" && m.hlsUrl ? (
<div className="post-media post-video" key={i}>
<VideoPlayer hlsUrl={m.hlsUrl} poster={m.src} label={`Video from ${post.authorName}`} />
</div>
) : m.src ? (
<div className={m.kind === "video" ? "post-media post-video" : "post-media"} key={i}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={m.src} alt={m.kind === "video" ? "Video thumbnail" : ""} loading="lazy" />
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { choosePlayback, supportsNativeHls } from "@/lib/video-playback";
export interface VideoPlayerProps {
hlsUrl: string;
poster: string | null;
label: string;
}
/**
* Real <video controls>. Native HLS where the browser has it, otherwise hls.js,
* loaded only when a video card mounts. Never autoplays.
*/
export function VideoPlayer({ hlsUrl, poster, label }: VideoPlayerProps) {
const ref = useRef<HTMLVideoElement>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
const video = ref.current;
if (!video) return;
let destroy: (() => void) | undefined;
let cancelled = false;
const native = supportsNativeHls(video);
if (native) {
if (choosePlayback(hlsUrl, { nativeHls: true, hlsJsSupported: false }) === "native") {
video.src = hlsUrl;
}
return;
}
import("hls.js")
.then(({ default: Hls }) => {
if (cancelled) return;
const mode = choosePlayback(hlsUrl, { nativeHls: false, hlsJsSupported: Hls.isSupported() });
if (mode !== "hlsjs") {
setFailed(true);
return;
}
const hls = new Hls();
hls.on(Hls.Events.ERROR, (_e, data) => {
if (data.fatal) setFailed(true);
});
hls.loadSource(hlsUrl);
hls.attachMedia(video);
destroy = () => hls.destroy();
})
.catch(() => setFailed(true));
return () => {
cancelled = true;
destroy?.();
};
}, [hlsUrl]);
if (failed && poster) {
// eslint-disable-next-line @next/next/no-img-element
return <img src={poster} alt="Video thumbnail" loading="lazy" />;
}
return (
<video
ref={ref}
controls
playsInline
preload="none"
poster={poster ?? undefined}
aria-label={label}
/>
);
}
+17 -1
View File
@@ -5,6 +5,8 @@ export interface CardMedia {
kind: "image" | "video";
/** Image source (images) or poster (videos). May be null for a bare video. */
src: string | null;
/** Mux HLS playlist (videos only). Null when the video is not ready yet. */
hlsUrl?: string | null;
}
export interface CardPost {
@@ -47,6 +49,18 @@ function authorName(author: Obj): string {
return full || "Unknown";
}
/** The HLS URL only when it is https on stream.mux.com. */
export function muxHlsUrl(v: unknown): string | null {
const s = str(v);
if (!s) return null;
try {
const u = new URL(s);
return u.protocol === "https:" && u.hostname === "stream.mux.com" ? s : null;
} catch {
return null;
}
}
function toMedia(raw: unknown): CardMedia[] {
if (!Array.isArray(raw)) return [];
const items = raw
@@ -56,7 +70,9 @@ function toMedia(raw: unknown): CardMedia[] {
const out: CardMedia[] = [];
for (const { m } of items) {
if (m.mediaType === "video") {
out.push({ kind: "video", src: str(m.thumbnailUrl) });
const thumb = str(m.thumbnailUrl);
const hls = muxHlsUrl(m.hlsUrl);
if (thumb || hls) out.push({ kind: "video", src: thumb, hlsUrl: hls });
continue;
}
const src = str(m.imageMediumWebpUrl) ?? str(m.imageMediumUrl) ?? str(m.imageUrl);
+21
View File
@@ -0,0 +1,21 @@
// Chooses how a feed video plays. Pure, so it is unit-testable without a browser.
export type Playback = "none" | "native" | "hlsjs";
export interface PlaybackEnv {
/** True when the browser can play .m3u8 directly (Safari). */
nativeHls: boolean;
/** True when hls.js (Media Source Extensions) is supported. */
hlsJsSupported: boolean;
}
/** No hlsUrl: thumbnail only. Native HLS wins; hls.js is the fallback. */
export function choosePlayback(hlsUrl: string | null | undefined, env: PlaybackEnv): Playback {
if (!hlsUrl) return "none";
if (env.nativeHls) return "native";
return env.hlsJsSupported ? "hlsjs" : "none";
}
export function supportsNativeHls(video: Pick<HTMLVideoElement, "canPlayType">): boolean {
return video.canPlayType("application/vnd.apple.mpegurl") !== "";
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { PostCard } from "@/components/PostCard";
import { toCardPosts } from "@/lib/feed-model";
import { choosePlayback, supportsNativeHls } from "@/lib/video-playback";
const HLS = "https://stream.mux.com/abc.m3u8";
const item = (media: Record<string, unknown>) => ({
type: "post",
publishedAt: "2026-10-19T10:00:00Z",
post: { id: "1", content: "hi", author: { displayName: "Ann" }, mediaItems: [media] },
});
const html = (media: Record<string, unknown>) =>
renderToStaticMarkup(createElement(PostCard, { post: toCardPosts([item(media)])[0] }));
describe("choosePlayback", () => {
it("no hlsUrl means no player", () => {
expect(choosePlayback(null, { nativeHls: true, hlsJsSupported: true })).toBe("none");
expect(choosePlayback(undefined, { nativeHls: false, hlsJsSupported: true })).toBe("none");
});
it("uses native HLS where supported, hls.js otherwise", () => {
expect(choosePlayback(HLS, { nativeHls: true, hlsJsSupported: true })).toBe("native");
expect(choosePlayback(HLS, { nativeHls: false, hlsJsSupported: true })).toBe("hlsjs");
});
it("falls back to none when neither is available", () => {
expect(choosePlayback(HLS, { nativeHls: false, hlsJsSupported: false })).toBe("none");
});
it("detects native HLS from canPlayType", () => {
expect(supportsNativeHls({ canPlayType: () => "maybe" })).toBe(true);
expect(supportsNativeHls({ canPlayType: () => "" })).toBe(false);
});
});
describe("video media in the card", () => {
const video = { mediaType: "video", thumbnailUrl: "https://i/v.jpg" };
it("hlsUrl present renders an accessible controls player with the poster", () => {
const out = html({ ...video, hlsUrl: HLS });
expect(out).toContain("<video");
expect(out).toContain("controls");
expect(out).toContain('poster="https://i/v.jpg"');
expect(out).toContain('aria-label="Video from Ann"');
expect(out).not.toContain("autoplay");
});
it("hlsUrl absent shows the thumbnail only, no player", () => {
const out = html(video);
expect(out).not.toContain("<video");
expect(out).toContain('src="https://i/v.jpg"');
});
it("rejects an hlsUrl not on stream.mux.com", () => {
const out = html({ ...video, hlsUrl: "https://evil.example/x.m3u8" });
expect(out).not.toContain("<video");
expect(out).toContain('src="https://i/v.jpg"');
});
});