| |
| |
| |
| |
| 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(() => { |
| |
| fetch('/api/config') |
| .then(res => res.json()) |
| .then(data => { |
| setStreamConfig({ |
| rtmpUrl: data.rtmpUrl, |
| streamKey: uuidv4().substring(0, 13).replace(/-/g, ''), |
| 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) { |
| |
| } |
| }; |
|
|
| 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(); |
| |
| |
| |
| 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) { |
| |
| setIsStreaming(false); |
| } |
| }); |
|
|
| flvPlayer.on(flvjs.Events.STATISTICS_INFO, (statInfo) => { |
| setIsStreaming(true); |
| |
| setStats(prev => ({ |
| ...prev, |
| fps: Math.round(statInfo.decodedFrames / Math.max(1, (videoRef.current?.currentTime || 1)) ), |
| kbps: Math.round(statInfo.speed * 8), |
| droppedFrames: statInfo.droppedFrames || 0, |
| resolution: `${videoRef.current?.videoWidth || 0}x${videoRef.current?.videoHeight || 0}` |
| })); |
| }); |
|
|
| flvPlayerRef.current = flvPlayer; |
| setIsStreaming(true); |
|
|
| |
| 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 = ""; |
| } |
| 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> |
|
|
| {} |
| <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> |
|
|
| {} |
| <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> |
| ); |
| } |
|
|