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.
137 lines
4.4 KiB
TypeScript
137 lines
4.4 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from "react";
|
||
import type { StreamStatus, Channel } from "./types";
|
||
import * as api from "./api/client";
|
||
import Player from "./components/Player";
|
||
import StatusBar from "./components/StatusBar";
|
||
import AdminPanel from "./components/AdminPanel";
|
||
import ChannelList from "./components/ChannelList";
|
||
import "./App.css";
|
||
|
||
// Detect touch-primary devices (phones/tablets have no hover pointer).
|
||
const isTouchPrimary = () =>
|
||
window.matchMedia("(pointer: coarse)").matches || navigator.maxTouchPoints > 0;
|
||
|
||
const HIDE_DELAY = 3000; // ms of inactivity before toggle hides again
|
||
|
||
export default function App() {
|
||
const [status, setStatus] = useState<StreamStatus | null>(null);
|
||
const [channels, setChannels] = useState<Channel[]>([]);
|
||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||
const [toggleVisible, setToggleVisible] = useState(false);
|
||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
const fetchStatus = useCallback(async () => {
|
||
try {
|
||
const s = await api.getStatus();
|
||
setStatus(s);
|
||
} catch {
|
||
// backend unreachable
|
||
}
|
||
}, []);
|
||
|
||
const fetchChannels = useCallback(async () => {
|
||
try {
|
||
const ch = await api.getChannels();
|
||
setChannels(ch);
|
||
} catch {
|
||
// backend unreachable
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetchStatus();
|
||
fetchChannels();
|
||
// Poll faster while live so passive viewers detect channel switches promptly.
|
||
// 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);
|
||
}, [fetchStatus, fetchChannels, status?.live, status?.transitioning]);
|
||
|
||
useEffect(() => {
|
||
if (status?.playlist_version) {
|
||
fetchChannels();
|
||
}
|
||
}, [fetchChannels, status?.playlist_version]);
|
||
|
||
// Show the toggle button on interaction, then auto-hide after HIDE_DELAY.
|
||
// When the sidebar is open we keep it visible so the user can close it.
|
||
const showToggle = useCallback(() => {
|
||
setToggleVisible(true);
|
||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||
if (!sidebarOpen) {
|
||
hideTimer.current = setTimeout(() => setToggleVisible(false), HIDE_DELAY);
|
||
}
|
||
}, [sidebarOpen]);
|
||
|
||
// Keep toggle visible while sidebar is open; restart timer when it closes.
|
||
useEffect(() => {
|
||
if (sidebarOpen) {
|
||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||
setToggleVisible(true);
|
||
} else {
|
||
hideTimer.current = setTimeout(() => setToggleVisible(false), HIDE_DELAY);
|
||
}
|
||
return () => {
|
||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||
};
|
||
}, [sidebarOpen]);
|
||
|
||
// Wire up interaction listeners on the document.
|
||
useEffect(() => {
|
||
const touch = isTouchPrimary();
|
||
if (touch) {
|
||
document.addEventListener("touchstart", showToggle, { passive: true });
|
||
return () => document.removeEventListener("touchstart", showToggle);
|
||
} else {
|
||
document.addEventListener("mousemove", showToggle);
|
||
return () => document.removeEventListener("mousemove", showToggle);
|
||
}
|
||
}, [showToggle]);
|
||
|
||
const handlePlaylistReloaded = () => {
|
||
fetchChannels();
|
||
};
|
||
|
||
const handleChannelRefresh = () => {
|
||
fetchStatus();
|
||
};
|
||
|
||
return (
|
||
<div className="app">
|
||
{/* Fullscreen video background */}
|
||
<div className="stage">
|
||
<Player
|
||
live={status?.live ?? false}
|
||
streamKey={status?.channel_name ?? "offline"}
|
||
/>
|
||
</div>
|
||
|
||
{/* Collapsible right sidebar */}
|
||
<button
|
||
className={`sidebar-toggle ${sidebarOpen ? "open" : ""} ${toggleVisible ? "visible" : ""}`}
|
||
onClick={() => setSidebarOpen((v) => !v)}
|
||
aria-label={sidebarOpen ? "Collapse panel" : "Open panel"}
|
||
title={sidebarOpen ? "Collapse panel" : "Open panel"}
|
||
>
|
||
{sidebarOpen ? "›" : "‹"}
|
||
</button>
|
||
|
||
<aside className={`sidebar ${sidebarOpen ? "open" : "collapsed"}`}>
|
||
<div className="sidebar-inner">
|
||
<AdminPanel onPlaylistReloaded={handlePlaylistReloaded} />
|
||
<ChannelList
|
||
channels={channels}
|
||
activeChannel={status?.channel_name ?? ""}
|
||
playlistVersion={status?.playlist_version ?? 0}
|
||
onRefresh={handleChannelRefresh}
|
||
/>
|
||
</div>
|
||
</aside>
|
||
|
||
{/* Status banner fixed to the bottom */}
|
||
<StatusBar status={status} />
|
||
</div>
|
||
);
|
||
}
|