vycxexra8: Play feed videos from hlsUrl with a thumbnail fallback
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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";
|
||||
/** 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);
|
||||
|
||||
@@ -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") !== "";
|
||||
}
|
||||
Reference in New Issue
Block a user