Tuner v1: HLS pipeline with playback/ingest modes

FFmpeg -> MediaMTX -> HLS relay with runtime-configurable modes:
- PLAYBACK_MODE: standard (mpegts), fmp4 (HEVC), llhls
- INGEST_MODE: rtmp, rtsp
- INGEST_AUDIO_MODE: aac, copy
- Hourly playlist auto-refresh from upstream URL
- Fullscreen video UI with collapsible sidebar, upstream-down banner
- Same-origin HLS proxy for HTTPS deployments

Tagged as the last HLS-based version before the mpegts.js (v2) rework.
This commit is contained in:
2026-06-12 19:17:15 -07:00
parent a41c3ee56c
commit 88a189de34
20 changed files with 814 additions and 123 deletions

View File

@@ -1,75 +1,137 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import Hls from "hls.js";
import type { PlaybackMode } from "../types";
interface Props {
live: boolean;
mode: PlaybackMode;
streamKey: string;
}
export default function Player({ live }: Props) {
export default function Player({ live, mode, streamKey }: Props) {
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [playing, setPlaying] = useState(false);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
// Tear down any existing instance when stream goes offline
const clearRetry = () => {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
};
// Tear down when stream goes offline
if (!live) {
clearRetry();
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
video.removeAttribute("src");
video.load();
setPlaying(false);
return;
}
const src = `http://${window.location.hostname}:8888/live/stream/index.m3u8`;
const src = `${window.location.origin}/live/stream/index.m3u8?v=${encodeURIComponent(streamKey)}`;
if (Hls.isSupported()) {
const hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 6,
liveBackBufferLength: 0,
maxBufferLength: 10,
maxMaxBufferLength: 30,
});
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) {
setTimeout(() => hls.loadSource(src), 5000);
setPlaying(false);
retryTimerRef.current = setTimeout(() => hls.loadSource(src), 5000);
} else {
hls.destroy();
setPlaying(false);
}
}
});
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = src;
video.addEventListener("loadedmetadata", () => {
// 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);
};
}
return () => {
clearRetry();
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
setPlaying(false);
};
}, [live]);
}, [live, mode, streamKey]);
return (
<div className="player-container">
<video ref={videoRef} className="player-video" controls playsInline />
<div className="player-offline">Stream Offline</div>
<video ref={videoRef} className="player-video" controls playsInline autoPlay muted />
{!playing && <div className="player-offline">Stream Offline</div>}
</div>
);
}