qhkr2z22o: Prefer direct mp4Url for feed video playback with hls fallback

This commit is contained in:
2026-10-19 17:11:33 -06:00
parent 9719b8dc83
commit 102f2f534a
5 changed files with 78 additions and 14 deletions
+2 -2
View File
@@ -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}
{post.text ? <p className="post-text">{post.text}</p> : null}
{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}>
<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>
) : m.src ? (
<div className={m.kind === "video" ? "post-media post-video" : "post-media"} key={i}>
+16 -6
View File
@@ -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 <video controls>. hls.js (loaded only when a video card mounts) is
* preferred; native HLS is used only where hls.js is unsupported. Never
* Real <video controls>. Order: direct mp4Url (plain progressive source), else
* 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.
*/
export function VideoPlayer({ hlsUrl, poster, label }: VideoPlayerProps) {
export function VideoPlayer({ mp4Url, hlsUrl, poster, label }: VideoPlayerProps) {
const ref = useRef<HTMLVideoElement>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
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 cancelled = false;
import("hls.js")
@@ -51,7 +54,7 @@ export function VideoPlayer({ hlsUrl, poster, label }: VideoPlayerProps) {
cancelled = true;
destroy?.();
};
}, [hlsUrl]);
}, [mp4Url, hlsUrl]);
if (failed) {
return (
@@ -64,9 +67,16 @@ export function VideoPlayer({ hlsUrl, poster, label }: VideoPlayerProps) {
</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 (
<video
ref={ref}
src={mp4Url ?? undefined}
controls
playsInline
preload="none"
+22 -5
View File
@@ -7,6 +7,8 @@ export interface CardMedia {
src: string | null;
/** Mux HLS playlist (videos only). Null when the video is not ready yet. */
hlsUrl?: string | null;
/** Direct progressive MP4 on the feed CDN (videos only). Preferred for playback. */
mp4Url?: string | null;
}
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[] {
if (!Array.isArray(raw)) return [];
const items = raw
@@ -76,8 +90,9 @@ function toMedia(raw: unknown): CardMedia[] {
if (m.mediaType === "video") {
const thumb = str(m.thumbnailUrl);
const hls = muxHlsUrl(m.hlsUrl);
// Keep a video even with no thumbnail or hlsUrl yet (just published).
out.push({ kind: "video", src: thumb, hlsUrl: hls });
const mp4 = feedMp4Url(m.mp4Url);
// Keep a video even with no thumbnail or playable URL yet (just published).
out.push({ kind: "video", src: thumb, hlsUrl: hls, mp4Url: mp4 });
continue;
}
const src = str(m.imageMediumWebpUrl) ?? str(m.imageMediumUrl) ?? str(m.imageUrl);
@@ -143,10 +158,12 @@ export function mergePosts(...lists: CardPost[][]): CardPost[] {
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.
* 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 };
}