Files
tuner/frontend/src/components/Player.tsx
Scott Register 03296e7572 v2: tune playback for bursty upstream + clean overflow handling
- 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.
2026-06-12 19:48:33 -07:00

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>
);
}