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:
2026-06-12 19:33:06 -07:00
parent 88a189de34
commit 723ae1f1ac
18 changed files with 432 additions and 899 deletions

View File

@@ -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">