Files
tuner/frontend/src/App.tsx
Scott Register 723ae1f1ac 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.
2026-06-12 19:33:06 -07:00

137 lines
4.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}