rtmp / src /App.tsx
wuhp's picture
Update src/App.tsx
f918470 verified
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useRef, useEffect, useCallback } from 'react';
import {
MonitorPlay,
SquareSquare,
StopCircle,
Activity,
Settings2,
Video,
Radio,
Eye,
EyeOff,
Copy,
Check
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import flvjs from 'flv.js';
import { v4 as uuidv4 } from 'uuid';
export default function App() {
const [isStreaming, setIsStreaming] = useState(false);
const [streamConfig, setStreamConfig] = useState<{ rtmpUrl: string, streamKey: string, isNgrok?: boolean } | null>(null);
const [showKey, setShowKey] = useState(false);
const [copied, setCopied] = useState(false);
const [isStreamActiveBackend, setIsStreamActiveBackend] = useState(false);
const [stats, setStats] = useState({
fps: 0, kbps: 0, droppedFrames: 0, codec: 'H.264/AAC', resolution: 'Awaiting Signal', jitter: 0
});
const videoRef = useRef<HTMLVideoElement>(null);
const flvPlayerRef = useRef<flvjs.Player | null>(null);
const statsIntervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
// Generate stream key and get RTMP URL on mount
fetch('/api/config')
.then(res => res.json())
.then(data => {
setStreamConfig({
rtmpUrl: data.rtmpUrl,
streamKey: uuidv4().substring(0, 13).replace(/-/g, ''), // e.g. a1b2c3d4e5f6
isNgrok: data.isNgrok
});
});
}, []);
useEffect(() => {
if (!streamConfig?.streamKey) return;
let mounted = true;
const checkBackendActive = async () => {
try {
const res = await fetch(`/api/streams/live/${streamConfig.streamKey}`);
if (!res.ok) return;
const data = await res.json();
if (mounted) {
setIsStreamActiveBackend(data.active);
}
} catch (e) {
// ignore fetch errors
}
};
checkBackendActive();
const interval = setInterval(checkBackendActive, 2000);
return () => {
mounted = false;
clearInterval(interval);
};
}, [streamConfig?.streamKey]);
const copyToClipboard = () => {
if (streamConfig) {
navigator.clipboard.writeText(streamConfig.streamKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const startPlayer = () => {
if (!flvjs.isSupported() || !videoRef.current || !streamConfig) return;
if (flvPlayerRef.current) {
flvPlayerRef.current.destroy();
}
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';
const host = window.location.host;
const fetchUrl = `${protocol}//${host}/live/${streamConfig.streamKey}.flv`;
const flvPlayer = flvjs.createPlayer({
type: 'flv',
url: fetchUrl,
isLive: true,
hasAudio: true,
hasVideo: true,
});
flvPlayer.attachMediaElement(videoRef.current);
flvPlayer.load();
// We try to play, but it might fail if there's no stream yet.
// The user should start the player AFTER starting the stream in OBS, or we can handle errors.
try {
const playPromise = flvPlayer.play() as Promise<void> | undefined;
if (playPromise !== undefined) {
playPromise.catch(() => {
console.log("Playback failed. Awaiting stream...");
});
}
} catch (e) {
console.log("Playback failed. Awaiting stream...", e);
}
flvPlayer.on(flvjs.Events.ERROR, (errType, errDetail) => {
console.error("FLV Player Error:", errType, errDetail);
if (errType === flvjs.ErrorTypes.NETWORK_ERROR) {
// Stream not live yet, or disconnected
setIsStreaming(false);
}
});
flvPlayer.on(flvjs.Events.STATISTICS_INFO, (statInfo) => {
setIsStreaming(true); // if we get stats, we are receiving video!
setStats(prev => ({
...prev,
fps: Math.round(statInfo.decodedFrames / Math.max(1, (videoRef.current?.currentTime || 1)) ), // Rough estimate or just rely on NMS
kbps: Math.round(statInfo.speed * 8), // speed is usually bytes per second in some versions, or kbps
droppedFrames: statInfo.droppedFrames || 0,
resolution: `${videoRef.current?.videoWidth || 0}x${videoRef.current?.videoHeight || 0}`
}));
});
flvPlayerRef.current = flvPlayer;
setIsStreaming(true);
// Simulated Stats polling (since flv.js stats might be limited)
statsIntervalRef.current = setInterval(() => {
if (videoRef.current && videoRef.current.videoWidth > 0) {
setStats(prev => ({
...prev,
resolution: `${videoRef.current?.videoWidth}x${videoRef.current?.videoHeight}`
}));
}
}, 1000);
};
const stopPlayer = () => {
if (flvPlayerRef.current) {
flvPlayerRef.current.destroy();
flvPlayerRef.current = null;
}
if (statsIntervalRef.current) {
clearInterval(statsIntervalRef.current);
}
if (videoRef.current) {
videoRef.current.src = ""; // Clear video
}
setIsStreaming(false);
setStats({
fps: 0, kbps: 0, droppedFrames: 0, codec: 'H.264/AAC', resolution: 'Awaiting Signal', jitter: 0
});
};
useEffect(() => {
return stopPlayer;
}, []);
return (
<div className="min-h-screen bg-[#0a0a0b] text-zinc-400 font-sans p-6 overflow-hidden flex flex-col">
{/* Header Section */}
<header className="flex items-center justify-between mb-6 border-b border-zinc-800 pb-4">
<div className="flex items-center gap-4">
<div className="w-3 h-3 bg-red-600 rounded-full shadow-[0_0_8px_#dc2626] animate-pulse"></div>
<h1 className="text-xl font-bold tracking-tight text-zinc-100 flex items-center gap-2">
<Radio className="w-5 h-5 text-emerald-400" />
RTMP COMMAND CENTER <span className="text-zinc-600 font-normal ml-2 text-sm hidden sm:inline">v2.4.0</span>
</h1>
</div>
<div className="flex gap-6 items-center">
<div className="text-right">
<p className="text-[10px] uppercase tracking-widest text-zinc-500">Node Media Server</p>
<p className="text-emerald-500 font-mono text-sm">OPERATIONAL</p>
</div>
<div className="h-8 w-px bg-zinc-800 hidden sm:block"></div>
<div className="text-right hidden sm:block">
<p className="text-[10px] uppercase tracking-widest text-zinc-500">Player State</p>
<p className="text-zinc-100 font-mono text-sm">{isStreaming ? 'CONNECTED' : 'DISCONNECTED'}</p>
</div>
</div>
</header>
<main className="flex-1 flex flex-col lg:flex-row gap-6 min-h-0 overflow-y-auto lg:overflow-visible">
{/* Sidebar Left: Config */}
<div className="w-full lg:w-80 flex flex-col gap-4 z-20">
<div className="bg-zinc-900/50 border border-zinc-800 p-4 rounded-lg">
<h3 className="text-[10px] font-bold uppercase tracking-widest mb-4 text-zinc-500 flex items-center gap-2">
<Settings2 className="w-3 h-3" />
Ingest Configuration
</h3>
<div className="space-y-4">
<div className="space-y-1">
<label className="text-[11px] text-zinc-400">Server URL (OBS / Streamlabs)</label>
<div className="bg-black border border-zinc-700 px-3 py-2 text-xs font-mono text-emerald-400 rounded break-all">
{streamConfig?.rtmpUrl || 'Loading...'}
</div>
</div>
<div className="space-y-1">
<label className="text-[11px] text-zinc-400">Stream Key</label>
<div className="bg-black border border-zinc-700 px-3 py-2 text-xs font-mono text-zinc-500 rounded flex justify-between items-center group">
<span className={showKey ? "text-zinc-100" : ""}>
{showKey ? streamConfig?.streamKey : "•••• •••• •••• ••••"}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setShowKey(!showKey)}
className="text-zinc-600 hover:text-zinc-400 transition-colors"
>
{showKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
<button
onClick={copyToClipboard}
className="text-zinc-600 hover:text-zinc-400 transition-colors"
>
{copied ? <Check className="w-4 h-4 text-emerald-500" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
</div>
</div>
</div>
<div className="bg-zinc-900/50 border border-zinc-800 p-4 rounded-lg">
<h3 className="text-[10px] font-bold uppercase tracking-widest mb-4 text-zinc-500 flex items-center gap-2">
<SquareSquare className="w-3 h-3" />
Source Details
</h3>
<div className="space-y-3">
<div className="flex justify-between items-end border-b border-zinc-800 pb-2">
<span className="text-xs">Incoming Resp</span>
<span className="text-sm font-mono text-zinc-100">{stats.resolution}</span>
</div>
<div className="flex justify-between items-end border-b border-zinc-800 pb-2">
<span className="text-xs">Video Codec</span>
<span className="text-sm font-mono text-zinc-100">H.264 / AVC</span>
</div>
<div className="flex justify-between items-end border-b border-zinc-800 pb-2">
<span className="text-xs">Audio Codec</span>
<span className="text-sm font-mono text-zinc-100">AAC 48kHz</span>
</div>
</div>
</div>
<div className="bg-zinc-900/50 border border-zinc-800 p-4 rounded-lg flex-1">
<h3 className="text-[10px] font-bold uppercase tracking-widest mb-4 text-zinc-500">System Logs</h3>
<div className="text-xs text-zinc-500 font-mono space-y-2">
<p><span className="text-zinc-700">[SYSTEM]</span> RTMP Server bound to :1935</p>
<p><span className="text-zinc-700">[SYSTEM]</span> HTTP-FLV muxing active</p>
{streamConfig?.isNgrok ? (
<p className="text-emerald-500 font-bold mt-2">
Bore.pub TCP Tunnel is ACTIVE! Ready for Streamlabs connection.
</p>
) : (
<>
<p className="text-amber-500/80 mt-2">
Establishing proxy tunnel for OBS / Streamlabs compatibility...
</p>
</>
)}
</div>
</div>
{!isStreaming ? (
<motion.button
whileHover={isStreamActiveBackend ? { scale: 1.02 } : {}}
whileTap={isStreamActiveBackend ? { scale: 0.98 } : {}}
onClick={isStreamActiveBackend ? startPlayer : undefined}
className={`w-full py-3 font-bold text-xs rounded transition-colors flex items-center justify-center gap-2 ${
isStreamActiveBackend
? 'bg-zinc-100 text-black hover:bg-white cursor-pointer'
: 'bg-zinc-800 text-zinc-500 cursor-not-allowed border border-zinc-700'
}`}
>
{isStreamActiveBackend ? (
<>
<MonitorPlay className="w-4 h-4" />
CONNECT PLAYER
</>
) : (
<>
<div className="w-3 h-3 border-2 border-zinc-500 border-t-zinc-400 rounded-full animate-spin"></div>
AWAITING STREAM...
</>
)}
</motion.button>
) : (
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={stopPlayer}
className="w-full py-3 bg-black text-rose-500 border border-rose-900 font-bold text-xs rounded hover:bg-rose-950 transition-colors flex items-center justify-center gap-2"
>
<StopCircle className="w-4 h-4" />
DISCONNECT PLAYER
</motion.button>
)}
</div>
{/* Main Video View */}
<div className="flex-1 flex flex-col min-w-0">
<div className="relative bg-black rounded-xl overflow-hidden border border-zinc-700 aspect-video group shadow-2xl">
{/* Placeholder if not streaming */}
<AnimatePresence>
{!isStreaming && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="absolute inset-0 flex flex-col items-center justify-center text-zinc-600 z-10"
>
<div className="w-16 h-16 border-2 border-zinc-700/50 rounded-full flex items-center justify-center mb-4">
<Video className="w-6 h-6 opacity-50" />
</div>
<h3 className="text-sm font-bold text-zinc-500 tracking-widest uppercase mb-1">AWAITING RTMP SIGNAL</h3>
<p className="text-[10px] uppercase text-zinc-600 mt-2 text-center max-w-xs">
Start streaming from OBS to the Server URL, then click Connect Player.
</p>
</motion.div>
)}
</AnimatePresence>
<video
ref={videoRef}
autoPlay
playsInline
muted
className={`w-full h-full object-contain transition-opacity duration-500 ${isStreaming ? 'opacity-100' : 'opacity-0'}`}
/>
{/* Tech HUD on Video */}
<AnimatePresence>
{isStreaming && (
<motion.div initial={{opacity: 0}} animate={{opacity: 1}} exit={{opacity: 0}}>
{/* Top Left */}
<div className="absolute top-4 left-4 flex gap-2">
<span className="bg-red-600/90 text-white text-[10px] font-bold px-2 py-0.5 rounded animate-pulse">LIVE</span>
<span className="bg-black/60 text-white text-[10px] font-mono px-2 py-0.5 rounded backdrop-blur-md border border-white/10">{stats.resolution}</span>
</div>
{/* Bottom Right */}
<div className="absolute bottom-4 right-4">
<span className="bg-black/60 text-emerald-400 text-[10px] font-mono px-2 py-0.5 rounded border border-emerald-400/20">FLV.JS DEMUXER</span>
</div>
{/* Viewfinder Corners */}
<div className="absolute top-4 right-4 w-4 h-4 border-t-2 border-r-2 border-white/20"></div>
<div className="absolute bottom-4 left-4 w-4 h-4 border-b-2 border-l-2 border-white/20"></div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Telemetry Dashboard */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mt-6">
<div className="bg-zinc-900 border border-zinc-800 p-3 rounded-lg overflow-hidden relative">
<p className="text-[10px] uppercase text-zinc-500 mb-1 tracking-widest">Network</p>
<div className="flex items-end gap-2">
<span className="text-xl md:text-2xl font-mono text-zinc-100">{stats.kbps.toLocaleString()}</span>
<span className="text-[10px] mb-1">kbps</span>
</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 p-3 rounded-lg overflow-hidden relative">
<p className="text-[10px] uppercase text-zinc-500 mb-1 tracking-widest">Client Frame Rate</p>
<div className="flex items-end gap-2">
<span className="text-xl md:text-2xl font-mono text-emerald-400">{stats.fps > 0 ? stats.fps : '-'}</span>
<span className="text-[10px] mb-1">FPS</span>
</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 p-3 rounded-lg overflow-hidden relative">
<p className="text-[10px] uppercase text-zinc-500 mb-1 tracking-widest">Client Dropped</p>
<div className="flex items-end gap-2">
<span className={`text-xl md:text-2xl font-mono ${stats.droppedFrames > 0 ? 'text-rose-400' : 'text-zinc-100'}`}>{stats.droppedFrames}</span>
<span className="text-[10px] mb-1">Frames</span>
</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 p-3 rounded-lg overflow-hidden relative">
<p className="text-[10px] uppercase text-zinc-500 mb-1 tracking-widest">Demuxer</p>
<div className="flex items-end gap-2">
<span className="text-xl font-mono text-zinc-100 truncate">{stats.codec.toUpperCase()}</span>
</div>
</div>
</div>
</div>
</main>
</div>
);
}