udpate stuff

This commit is contained in:
2026-06-11 09:48:17 -07:00
parent 6bd80a0b3a
commit a2df93097a
14 changed files with 294 additions and 26 deletions

View File

@@ -95,6 +95,76 @@ body {
.status-channel {
color: #67e8f9;
display: flex;
align-items: center;
gap: 8px;
}
.status-channel.channel-transitioning {
animation: channel-pulse 1.2s ease-in-out infinite;
}
.channel-switching {
font-size: 0.75rem;
font-weight: 600;
color: #fbbf24;
background: #713f12;
padding: 1px 6px;
border-radius: 3px;
animation: switching-blink 1s step-end infinite;
}
@keyframes channel-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes switching-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.status-health {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8rem;
font-family: "SF Mono", "Fira Code", monospace;
}
.health-indicator {
padding: 2px 8px;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 600;
}
.health-good {
background: #14532d;
color: #4ade80;
}
.health-warn {
background: #713f12;
color: #fbbf24;
}
.health-bad {
background: #7f1d1d;
color: #f87171;
}
.health-unknown {
background: #333;
color: #888;
}
.health-stats {
color: #888;
}
.health-drops {
color: #f87171;
}
/* Controls layout */

View File

@@ -32,9 +32,11 @@ export default function App() {
useEffect(() => {
fetchStatus();
fetchChannels();
const interval = setInterval(fetchStatus, 5000);
// Poll faster while transitioning so the UI updates promptly
const rate = status?.transitioning ? 1000 : 5000;
const interval = setInterval(fetchStatus, rate);
return () => clearInterval(interval);
}, [fetchStatus, fetchChannels]);
}, [fetchStatus, fetchChannels, status?.transitioning]);
const handleSourceChanged = () => {
fetchStatus();
@@ -50,7 +52,7 @@ export default function App() {
return (
<div className="app">
<Player />
<Player live={status?.live ?? false} />
<StatusBar status={status} />
<div className="controls">
<AdminPanel
@@ -60,7 +62,7 @@ export default function App() {
/>
<ChannelList
channels={channels}
activeChannel={status?.channelName ?? ""}
activeChannel={status?.channel_name ?? ""}
onRefresh={handleChannelRefresh}
/>
</div>

View File

@@ -15,14 +15,14 @@ export default function ChannelList({ channels, activeChannel, onRefresh }: Prop
const [filtered, setFiltered] = useState<Channel[]>(channels);
useEffect(() => {
api.getGroups().then(setGroups).catch(() => {});
api.getGroups().then((g) => setGroups(g ?? [])).catch(() => {});
}, []);
useEffect(() => {
const fetchFiltered = async () => {
try {
const result = await api.getChannels(search || undefined, group || undefined);
setFiltered(result);
setFiltered(result ?? []);
} catch {
setFiltered(channels);
}

View File

@@ -1,7 +1,11 @@
import { useEffect, useRef } from "react";
import Hls from "hls.js";
export default function Player() {
interface Props {
live: boolean;
}
export default function Player({ live }: Props) {
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
@@ -9,12 +13,28 @@ export default function Player() {
const video = videoRef.current;
if (!video) return;
const src = `http://${window.location.hostname}:8888/live/stream/`;
// Tear down any existing instance when stream goes offline
if (!live) {
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
video.removeAttribute("src");
video.load();
return;
}
const src = `http://${window.location.hostname}:8888/live/stream/index.m3u8`;
if (Hls.isSupported()) {
const hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 6,
liveBackBufferLength: 0,
maxBufferLength: 10,
maxMaxBufferLength: 30,
});
hlsRef.current = hls;
hls.loadSource(src);
@@ -44,7 +64,7 @@ export default function Player() {
hlsRef.current = null;
}
};
}, []);
}, [live]);
return (
<div className="player-container">

View File

@@ -4,6 +4,22 @@ 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 (
@@ -14,6 +30,8 @@ export default function StatusBar({ status }: Props) {
);
}
const h = status.health;
return (
<div className="status-bar">
<span className={`status-dot ${status.live ? "live" : "offline"}`} />
@@ -24,12 +42,32 @@ export default function StatusBar({ status }: Props) {
<span className="status-source">
Source: {status.source === "obs" ? "OBS" : "IPTV"}
</span>
{status.source === "iptv" && status.channelName && (
{status.source === "iptv" && status.channel_name && (
<>
<span className="status-separator">|</span>
<span className="status-channel">{status.channelName}</span>
<span className={`status-channel ${status.transitioning ? "channel-transitioning" : ""}`}>
{status.channel_name}
{status.transitioning && (
<span className="channel-switching">Switching...</span>
)}
</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>
);
}

View File

@@ -6,10 +6,20 @@ 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";
channelName: string;
channel_name: string;
live: boolean;
transitioning: boolean;
health?: StreamHealth;
}
export interface ProcessInfo {