Tuner v1: HLS pipeline with playback/ingest modes

FFmpeg -> MediaMTX -> HLS relay with runtime-configurable modes:
- PLAYBACK_MODE: standard (mpegts), fmp4 (HEVC), llhls
- INGEST_MODE: rtmp, rtsp
- INGEST_AUDIO_MODE: aac, copy
- Hourly playlist auto-refresh from upstream URL
- Fullscreen video UI with collapsible sidebar, upstream-down banner
- Same-origin HLS proxy for HTTPS deployments

Tagged as the last HLS-based version before the mpegts.js (v2) rework.
This commit is contained in:
2026-06-12 19:17:15 -07:00
parent a41c3ee56c
commit 88a189de34
20 changed files with 814 additions and 123 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from "react";
import type { StreamStatus, Channel } from "./types";
import { useState, useEffect, useCallback, useRef } from "react";
import type { StreamStatus, Channel, PlaybackMode } from "./types";
import * as api from "./api/client";
import Player from "./components/Player";
import StatusBar from "./components/StatusBar";
@@ -7,9 +7,32 @@ 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 [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 {
@@ -32,11 +55,53 @@ export default function App() {
useEffect(() => {
fetchStatus();
fetchChannels();
// Poll faster while transitioning so the UI updates promptly
const rate = status?.transitioning ? 1000 : 5000;
// Poll faster while live so passive viewers detect channel switches promptly.
// Transitioning gets the fastest rate so the UI clears quickly after FFmpeg starts.
const rate = status?.transitioning ? 1000 : status?.live ? 2000 : 5000;
const interval = setInterval(fetchStatus, rate);
return () => clearInterval(interval);
}, [fetchStatus, fetchChannels, status?.transitioning]);
}, [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 handleSourceChanged = () => {
fetchStatus();
@@ -52,20 +117,43 @@ export default function App() {
return (
<div className="app">
<Player live={status?.live ?? false} />
<StatusBar status={status} />
<div className="controls">
<AdminPanel
status={status}
onSourceChanged={handleSourceChanged}
onPlaylistReloaded={handlePlaylistReloaded}
/>
<ChannelList
channels={channels}
activeChannel={status?.channel_name ?? ""}
onRefresh={handleChannelRefresh}
{/* Fullscreen video background */}
<div className="stage">
<Player
live={status?.live ?? false}
mode={playbackMode}
streamKey={`${status?.source ?? "offline"}:${status?.channel_name ?? ""}`}
/>
</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
status={status}
onSourceChanged={handleSourceChanged}
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>
);
}