- Player: match webplayer.online's proven mpegts.js live-sync config (deep 30-90s buffer, 45s target latency, 1.1x catch-up). The upstream delivers in bursts with multi-second gaps (measured up to ~6s); the deep buffer rides over them so 4K HEVC plays smoothly. - Hub: on subscriber buffer overflow, cleanly disconnect the client so it reconnects and resyncs from a TS packet boundary instead of dropping bytes mid-stream (which corrupted alignment / sync_byte). - Enlarge per-client buffer (256 -> 2048 chunks). - Refresh real1.m3u from upstream.
149 lines
4.4 KiB
TypeScript
149 lines
4.4 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import mpegts from "mpegts.js";
|
|
|
|
interface Props {
|
|
live: boolean;
|
|
streamKey: string;
|
|
}
|
|
|
|
const MAX_RETRIES = 8;
|
|
|
|
export default function Player({ live, streamKey }: Props) {
|
|
const videoRef = useRef<HTMLVideoElement>(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 clearTimers = () => {
|
|
if (retryTimerRef.current) {
|
|
clearTimeout(retryTimerRef.current);
|
|
retryTimerRef.current = null;
|
|
}
|
|
if (stallTimerRef.current) {
|
|
clearInterval(stallTimerRef.current);
|
|
stallTimerRef.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;
|
|
}
|
|
};
|
|
|
|
// Tear down when offline
|
|
if (!live) {
|
|
clearTimers();
|
|
destroyPlayer();
|
|
try { video.removeAttribute("src"); video.load(); } catch { /* noop */ }
|
|
setPlaying(false);
|
|
return;
|
|
}
|
|
|
|
if (!mpegts.isSupported()) {
|
|
setPlaying(false);
|
|
return;
|
|
}
|
|
|
|
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();
|
|
// Config mirrors webplayer.online (proven on this exact bursty upstream):
|
|
// a deep 30-90s live buffer absorbs the upstream's multi-second delivery
|
|
// gaps, while gentle 1.1x catch-up keeps latency bounded.
|
|
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,
|
|
liveBufferLatencyMaxLatency: 90,
|
|
liveBufferLatencyMinRemain: 30,
|
|
autoCleanupSourceBuffer: true,
|
|
autoCleanupMaxBackwardDuration: 120,
|
|
autoCleanupMinBackwardDuration: 90,
|
|
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);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [live, streamKey]);
|
|
|
|
return (
|
|
<div className="player-container">
|
|
<video ref={videoRef} className="player-video" controls playsInline autoPlay muted />
|
|
{!playing && <div className="player-offline">Stream Offline</div>}
|
|
</div>
|
|
);
|
|
}
|