vycxexra8: Play feed videos from hlsUrl with a thumbnail fallback
This commit is contained in:
Generated
+7
@@ -8,6 +8,7 @@
|
|||||||
"name": "frontend-helper",
|
"name": "frontend-helper",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"hls.js": "1.7.3",
|
||||||
"next": "15.5.28",
|
"next": "15.5.28",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0"
|
"react-dom": "19.0.0"
|
||||||
@@ -4321,6 +4322,12 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"hls.js": "1.7.3",
|
||||||
"next": "15.5.28",
|
"next": "15.5.28",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0"
|
"react-dom": "19.0.0"
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ button:disabled { opacity: .5; cursor: not-allowed; }
|
|||||||
.post-who time { color: var(--grey); font-size: 13px; }
|
.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-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-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; }
|
.post-stats { display: flex; gap: 16px; color: var(--grey); font-size: 14px; }
|
||||||
.heart { color: var(--red); }
|
.heart { color: var(--red); }
|
||||||
.feed-message { text-align: center; color: var(--grey); padding: 32px 16px; }
|
.feed-message { text-align: center; color: var(--grey); padding: 32px 16px; }
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { VideoPlayer } from "@/components/VideoPlayer";
|
||||||
import { initials, relativeTime, type CardPost } from "@/lib/feed-model";
|
import { initials, relativeTime, type CardPost } from "@/lib/feed-model";
|
||||||
|
|
||||||
export function PostCard({ post, now }: { post: CardPost; now?: number }) {
|
export function PostCard({ post, now }: { post: CardPost; now?: number }) {
|
||||||
@@ -19,7 +20,11 @@ export function PostCard({ post, now }: { post: CardPost; now?: number }) {
|
|||||||
</header>
|
</header>
|
||||||
{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.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}>
|
<div className={m.kind === "video" ? "post-media post-video" : "post-media"} key={i}>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src={m.src} alt={m.kind === "video" ? "Video thumbnail" : ""} loading="lazy" />
|
<img src={m.src} alt={m.kind === "video" ? "Video thumbnail" : ""} loading="lazy" />
|
||||||
|
|||||||
@@ -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
@@ -5,6 +5,8 @@ export interface CardMedia {
|
|||||||
kind: "image" | "video";
|
kind: "image" | "video";
|
||||||
/** Image source (images) or poster (videos). May be null for a bare video. */
|
/** Image source (images) or poster (videos). May be null for a bare video. */
|
||||||
src: string | null;
|
src: string | null;
|
||||||
|
/** Mux HLS playlist (videos only). Null when the video is not ready yet. */
|
||||||
|
hlsUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CardPost {
|
export interface CardPost {
|
||||||
@@ -47,6 +49,18 @@ function authorName(author: Obj): string {
|
|||||||
return full || "Unknown";
|
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[] {
|
function toMedia(raw: unknown): CardMedia[] {
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
const items = raw
|
const items = raw
|
||||||
@@ -56,7 +70,9 @@ function toMedia(raw: unknown): CardMedia[] {
|
|||||||
const out: CardMedia[] = [];
|
const out: CardMedia[] = [];
|
||||||
for (const { m } of items) {
|
for (const { m } of items) {
|
||||||
if (m.mediaType === "video") {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
const src = str(m.imageMediumWebpUrl) ?? str(m.imageMediumUrl) ?? str(m.imageUrl);
|
const src = str(m.imageMediumWebpUrl) ?? str(m.imageMediumUrl) ?? str(m.imageUrl);
|
||||||
|
|||||||
@@ -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") !== "";
|
||||||
|
}
|
||||||
@@ -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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user