File size: 17,463 Bytes
b6290c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a96732
b6290c8
 
f918470
b6290c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a96732
 
b6290c8
 
 
 
f918470
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6290c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43a631d
 
 
 
b6290c8
 
43a631d
b6290c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a96732
 
f918470
4a96732
 
 
 
f918470
4a96732
 
 
b6290c8
 
 
 
 
f918470
 
 
 
 
 
 
 
b6290c8
f918470
 
 
 
 
 
 
 
 
 
 
b6290c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
/**
 * @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>
  );
}