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