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 ?? ""}

View File

@@ -1,4 +1,4 @@
import type { Channel, StreamStatus, SystemStatus, AppConfig } from "../types";
import type { Channel, StreamStatus } from "../types";
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
@@ -8,10 +8,6 @@ async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
return res.json();
}
export function getConfig(): Promise<AppConfig> {
return fetchJSON<AppConfig>("/api/config");
}
async function fetchVoid(url: string, init?: RequestInit): Promise<void> {
const res = await fetch(url, init);
if (!res.ok) {
@@ -35,14 +31,6 @@ export function getGroups(): Promise<string[]> {
return fetchJSON<string[]>("/api/admin/groups");
}
export function setSource(source: string): Promise<void> {
return fetchVoid("/api/admin/source", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source }),
});
}
export function setChannel(channelId: string): Promise<void> {
return fetchVoid("/api/admin/channel", {
method: "POST",
@@ -54,7 +42,3 @@ export function setChannel(channelId: string): Promise<void> {
export function reloadPlaylist(): Promise<void> {
return fetchVoid("/api/admin/playlist/reload", { method: "POST" });
}
export function getProcessStatus(): Promise<SystemStatus> {
return fetchJSON<SystemStatus>("/api/admin/process/status");
}

View File

@@ -1,24 +1,10 @@
import type { StreamStatus } from "../types";
import * as api from "../api/client";
interface Props {
status: StreamStatus | null;
onSourceChanged: () => void;
onPlaylistReloaded: () => void;
}
export default function AdminPanel({ status, onSourceChanged, onPlaylistReloaded }: Props) {
const currentSource = status?.source ?? "obs";
const handleSource = async (source: "obs" | "iptv") => {
try {
await api.setSource(source);
onSourceChanged();
} catch (err) {
console.error("Failed to set source:", err);
}
};
export default function AdminPanel({ onPlaylistReloaded }: Props) {
const handleReload = async () => {
try {
await api.reloadPlaylist();
@@ -30,21 +16,6 @@ export default function AdminPanel({ status, onSourceChanged, onPlaylistReloaded
return (
<div className="admin-panel">
<h2 className="admin-title">Source</h2>
<div className="source-buttons">
<button
className={`source-btn ${currentSource === "obs" ? "active" : ""}`}
onClick={() => handleSource("obs")}
>
OBS
</button>
<button
className={`source-btn ${currentSource === "iptv" ? "active" : ""}`}
onClick={() => handleSource("iptv")}
>
IPTV
</button>
</div>
<button className="reload-btn" onClick={handleReload}>
Refresh Channels / Playlist
</button>

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

View File

@@ -4,22 +4,6 @@ interface Props {
status: StreamStatus | null;
}
function healthColor(speed: string): string {
const n = parseFloat(speed);
if (isNaN(n)) return "health-unknown";
if (n >= 0.95) return "health-good";
if (n >= 0.8) return "health-warn";
return "health-bad";
}
function healthLabel(speed: string): string {
const n = parseFloat(speed);
if (isNaN(n)) return "?";
if (n >= 0.95) return "Good";
if (n >= 0.8) return "Slow upstream";
return "Upstream too slow";
}
export default function StatusBar({ status }: Props) {
if (!status) {
return (
@@ -30,19 +14,13 @@ export default function StatusBar({ status }: Props) {
);
}
const h = status.health;
return (
<div className="status-bar">
<span className={`status-dot ${status.live ? "live" : "offline"}`} />
<span className="status-text">
{status.live ? "LIVE" : "Offline"}
</span>
<span className="status-separator">|</span>
<span className="status-source">
Source: {status.source === "obs" ? "OBS" : "IPTV"}
</span>
{status.source === "iptv" && status.channel_name && (
{status.channel_name && (
<>
<span className="status-separator">|</span>
<span className={`status-channel ${status.transitioning ? "channel-transitioning" : ""}`}>
@@ -59,21 +37,6 @@ export default function StatusBar({ status }: Props) {
<span className="upstream-down-banner">Upstream down?</span>
</>
)}
{h && h.speed && (
<span className="status-health">
<span className="status-separator">|</span>
<span className={`health-indicator ${healthColor(h.speed)}`}>
{healthLabel(h.speed)}
</span>
<span className="health-stats">
{h.speed} / {h.bitrate}
{h.fps && h.fps !== "0.00" && <> / {h.fps}fps</>}
{h.drop_frames !== "0" && (
<span className="health-drops"> / {h.drop_frames} dropped</span>
)}
</span>
</span>
)}
</div>
);
}

View File

@@ -6,38 +6,10 @@ export interface Channel {
streamUrl: string;
}
export interface StreamHealth {
speed: string;
fps: string;
bitrate: string;
drop_frames: string;
dup_frames: string;
}
export interface StreamStatus {
source: "obs" | "iptv";
channel_name: string;
live: boolean;
transitioning: boolean;
upstream_down: boolean;
playlist_version: number;
health?: StreamHealth;
}
export interface ProcessInfo {
running: boolean;
pid: number;
uptime: string;
error: string;
}
export interface SystemStatus {
mediamtx: ProcessInfo;
ffmpeg: ProcessInfo;
}
export type PlaybackMode = "llhls" | "fmp4" | "standard";
export interface AppConfig {
playback_mode: PlaybackMode;
}