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:
@@ -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 ?? ""}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user