v2: mpegts.js + single-upstream fan-out hub (no FFmpeg/HLS)
Replaces the FFmpeg -> MediaMTX -> HLS pipeline with a raw MPEG-TS fan-out architecture: - Go stream.Hub holds ONE upstream connection per active channel and broadcasts the raw TS bytes to all connected browsers via /ts. The IPTV provider only ever sees one IP (the server), no matter how many viewers are watching (shared single-channel model). - Frontend uses mpegts.js to play the raw TS in-browser via MSE, fixing HEVC/H.265 4K streams that HLS could not package. - Dropped FFmpeg, MediaMTX, RTMP/RTSP, HLS proxy, playback/ingest modes, the OBS source toggle, and FFmpeg health stats. - UI look preserved (fullscreen video, collapsible sidebar, status bar, upstream-down banner, hourly playlist auto-refresh). Tradeoff: loses iOS Safari (no MSE/mpegts.js); fixes desktop/Android playback of both H.264 and H.265.
This commit is contained in:
@@ -1,132 +1,134 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Hls from "hls.js";
|
||||
import type { PlaybackMode } from "../types";
|
||||
import mpegts from "mpegts.js";
|
||||
|
||||
interface Props {
|
||||
live: boolean;
|
||||
mode: PlaybackMode;
|
||||
streamKey: string;
|
||||
}
|
||||
|
||||
export default function Player({ live, mode, streamKey }: Props) {
|
||||
const MAX_RETRIES = 8;
|
||||
|
||||
export default function Player({ live, streamKey }: Props) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
const playerRef = useRef<mpegts.Player | null>(null);
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const stallTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const retryCountRef = useRef(0);
|
||||
const lastProgressRef = useRef(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const clearRetry = () => {
|
||||
const clearTimers = () => {
|
||||
if (retryTimerRef.current) {
|
||||
clearTimeout(retryTimerRef.current);
|
||||
retryTimerRef.current = null;
|
||||
}
|
||||
if (stallTimerRef.current) {
|
||||
clearInterval(stallTimerRef.current);
|
||||
stallTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Tear down when stream goes offline
|
||||
if (!live) {
|
||||
clearRetry();
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
const destroyPlayer = () => {
|
||||
if (playerRef.current) {
|
||||
try { playerRef.current.pause(); } catch { /* noop */ }
|
||||
try { playerRef.current.unload(); } catch { /* noop */ }
|
||||
try { playerRef.current.detachMediaElement(); } catch { /* noop */ }
|
||||
try { playerRef.current.destroy(); } catch { /* noop */ }
|
||||
playerRef.current = null;
|
||||
}
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
};
|
||||
|
||||
// Tear down when offline
|
||||
if (!live) {
|
||||
clearTimers();
|
||||
destroyPlayer();
|
||||
try { video.removeAttribute("src"); video.load(); } catch { /* noop */ }
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const src = `${window.location.origin}/live/stream/index.m3u8?v=${encodeURIComponent(streamKey)}`;
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
const hlsConfig =
|
||||
mode === "llhls"
|
||||
? {
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
liveSyncDurationCount: 3,
|
||||
liveMaxLatencyDurationCount: 6,
|
||||
liveBackBufferLength: 0,
|
||||
maxBufferLength: 10,
|
||||
maxMaxBufferLength: 30,
|
||||
}
|
||||
: {
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
liveSyncDurationCount: 4,
|
||||
liveMaxLatencyDurationCount: 10,
|
||||
maxBufferLength: 30,
|
||||
maxMaxBufferLength: 60,
|
||||
};
|
||||
const hls = new Hls(hlsConfig);
|
||||
hlsRef.current = hls;
|
||||
hls.loadSource(src);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
video.play().catch(() => {});
|
||||
setPlaying(true);
|
||||
});
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (data.fatal) {
|
||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||
setPlaying(false);
|
||||
retryTimerRef.current = setTimeout(() => hls.loadSource(src), 5000);
|
||||
} else {
|
||||
hls.destroy();
|
||||
setPlaying(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
// Native HLS (iOS Safari) — set src directly and retry on error.
|
||||
const setupNative = () => {
|
||||
video.src = src;
|
||||
video.load();
|
||||
video.play().catch(() => {});
|
||||
};
|
||||
|
||||
const onPlaying = () => {
|
||||
clearRetry();
|
||||
setPlaying(true);
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
setPlaying(false);
|
||||
retryTimerRef.current = setTimeout(setupNative, 5000);
|
||||
};
|
||||
|
||||
const onStalled = () => {
|
||||
setPlaying(false);
|
||||
retryTimerRef.current = setTimeout(setupNative, 5000);
|
||||
};
|
||||
|
||||
video.addEventListener("playing", onPlaying);
|
||||
video.addEventListener("error", onError);
|
||||
video.addEventListener("stalled", onStalled);
|
||||
setupNative();
|
||||
|
||||
return () => {
|
||||
clearRetry();
|
||||
video.removeEventListener("playing", onPlaying);
|
||||
video.removeEventListener("error", onError);
|
||||
video.removeEventListener("stalled", onStalled);
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
setPlaying(false);
|
||||
};
|
||||
if (!mpegts.isSupported()) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearRetry();
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
const src = `${window.location.origin}/ts?v=${encodeURIComponent(streamKey)}`;
|
||||
|
||||
const scheduleRetry = () => {
|
||||
if (retryCountRef.current >= MAX_RETRIES) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
retryCountRef.current++;
|
||||
const delay = Math.min(2000 * retryCountRef.current, 10000);
|
||||
setPlaying(false);
|
||||
retryTimerRef.current = setTimeout(start, delay);
|
||||
};
|
||||
|
||||
const startStallWatchdog = () => {
|
||||
if (stallTimerRef.current) clearInterval(stallTimerRef.current);
|
||||
lastProgressRef.current = Date.now();
|
||||
const bump = () => { lastProgressRef.current = Date.now(); };
|
||||
video.addEventListener("timeupdate", bump);
|
||||
video.addEventListener("progress", bump);
|
||||
stallTimerRef.current = setInterval(() => {
|
||||
if (Date.now() - lastProgressRef.current > 30000) {
|
||||
lastProgressRef.current = Date.now();
|
||||
start();
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
function start() {
|
||||
destroyPlayer();
|
||||
const player = mpegts.createPlayer(
|
||||
{ type: "mpegts", isLive: true, url: src },
|
||||
{
|
||||
enableWorker: true,
|
||||
enableStashBuffer: true,
|
||||
stashInitialSize: 8 * 1024 * 1024,
|
||||
liveSync: true,
|
||||
liveSyncMaxLatency: 60,
|
||||
liveSyncTargetLatency: 45,
|
||||
liveSyncPlaybackRate: 1.1,
|
||||
liveBufferLatencyChasing: true,
|
||||
autoCleanupSourceBuffer: true,
|
||||
fixAudioTimestampGap: true,
|
||||
lazyLoad: false,
|
||||
}
|
||||
);
|
||||
playerRef.current = player;
|
||||
player.attachMediaElement(video!);
|
||||
|
||||
player.on(mpegts.Events.ERROR, () => {
|
||||
scheduleRetry();
|
||||
});
|
||||
player.on(mpegts.Events.LOADING_COMPLETE, () => {
|
||||
scheduleRetry();
|
||||
});
|
||||
player.on(mpegts.Events.MEDIA_INFO, () => {
|
||||
setPlaying(true);
|
||||
});
|
||||
|
||||
player.load();
|
||||
video!.play().catch(() => { /* autoplay handled via muted attribute */ });
|
||||
startStallWatchdog();
|
||||
}
|
||||
|
||||
retryCountRef.current = 0;
|
||||
start();
|
||||
|
||||
return () => {
|
||||
clearTimers();
|
||||
destroyPlayer();
|
||||
setPlaying(false);
|
||||
};
|
||||
}, [live, mode, streamKey]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [live, streamKey]);
|
||||
|
||||
return (
|
||||
<div className="player-container">
|
||||
|
||||
Reference in New Issue
Block a user