udpate stuff
This commit is contained in:
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user