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,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react";
import type { StreamStatus, Channel, PlaybackMode } from "./types";
import type { StreamStatus, Channel } from "./types";
import * as api from "./api/client";
import Player from "./components/Player";
import StatusBar from "./components/StatusBar";
@@ -17,23 +17,9 @@ export default function App() {
const [status, setStatus] = useState<StreamStatus | null>(null);
const [channels, setChannels] = useState<Channel[]>([]);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [playbackMode, setPlaybackMode] = useState<PlaybackMode>("standard");
const [toggleVisible, setToggleVisible] = useState(false);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
api
.getConfig()
.then((c) => {
if (c.playback_mode === "llhls" || c.playback_mode === "fmp4") {
setPlaybackMode(c.playback_mode);
} else {
setPlaybackMode("standard");
}
})
.catch(() => {});
}, []);
const fetchStatus = useCallback(async () => {
try {
const s = await api.getStatus();
@@ -56,7 +42,7 @@ export default function App() {
fetchStatus();
fetchChannels();
// Poll faster while live so passive viewers detect channel switches promptly.
// Transitioning gets the fastest rate so the UI clears quickly after FFmpeg starts.
// Transitioning gets the fastest rate so the UI clears quickly after a switch.
const rate = status?.transitioning ? 1000 : status?.live ? 2000 : 5000;
const interval = setInterval(fetchStatus, rate);
return () => clearInterval(interval);
@@ -103,10 +89,6 @@ export default function App() {
}
}, [showToggle]);
const handleSourceChanged = () => {
fetchStatus();
};
const handlePlaylistReloaded = () => {
fetchChannels();
};
@@ -121,8 +103,7 @@ export default function App() {
<div className="stage">
<Player
live={status?.live ?? false}
mode={playbackMode}
streamKey={`${status?.source ?? "offline"}:${status?.channel_name ?? ""}`}
streamKey={status?.channel_name ?? "offline"}
/>
</div>
@@ -138,11 +119,7 @@ export default function App() {
<aside className={`sidebar ${sidebarOpen ? "open" : "collapsed"}`}>
<div className="sidebar-inner">
<AdminPanel
status={status}
onSourceChanged={handleSourceChanged}
onPlaylistReloaded={handlePlaylistReloaded}
/>
<AdminPanel onPlaylistReloaded={handlePlaylistReloaded} />
<ChannelList
channels={channels}
activeChannel={status?.channel_name ?? ""}