import { useState, useEffect, useCallback, useRef } from 'react'; import { Map, Marker, Popup } from '@vis.gl/react-maplibre'; import 'maplibre-gl/dist/maplibre-gl.css'; import { fetchDroneStreams, startDroneStreamApi, stopDroneStreamApi } from '../services/aerialApi'; import type { DroneStreamItem } from '../services/aerialApi'; import { CCTVPlayer } from './CCTVPlayer'; import type { CCTVPlayerHandle } from './CCTVPlayer'; import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; import { S57EncOverlay } from '@common/components/map/S57EncOverlay'; import { useMapStore } from '@common/store/mapStore'; /** 함정 위치 + 드론 비행 위치 */ const DRONE_POSITIONS: Record< string, { ship: { lat: number; lon: number }; drone: { lat: number; lon: number } } > = { 'busan-1501': { ship: { lat: 35.0796, lon: 129.0756 }, drone: { lat: 35.11, lon: 129.11 } }, 'incheon-3008': { ship: { lat: 37.4541, lon: 126.5986 }, drone: { lat: 37.48, lon: 126.56 } }, 'mokpo-3015': { ship: { lat: 34.778, lon: 126.378 }, drone: { lat: 34.805, lon: 126.41 } }, }; export function RealtimeDrone() { const [streams, setStreams] = useState([]); const [loading, setLoading] = useState(true); const [selectedStream, setSelectedStream] = useState(null); const [gridMode, setGridMode] = useState(1); const [activeCells, setActiveCells] = useState([]); const [mapPopup, setMapPopup] = useState(null); const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([]); const currentMapStyle = useBaseMapStyle(); const mapToggles = useMapStore((s) => s.mapToggles); const showMap = activeCells.length === 0; const loadStreams = useCallback(async () => { try { const items = await fetchDroneStreams(); setStreams(items); // Update selected stream and active cells with latest status setSelectedStream((prev) => (prev ? (items.find((s) => s.id === prev.id) ?? prev) : prev)); setActiveCells((prev) => prev.map((cell) => items.find((s) => s.id === cell.id) ?? cell)); } catch { // Fallback: show configured streams as idle setStreams([ { id: 'busan-1501', name: '1501함 드론', shipName: '부산서 1501함', droneModel: 'DJI M300 RTK', ip: '10.26.7.213', rtspUrl: 'rtsp://10.26.7.213:554/stream0', region: '부산', status: 'idle', hlsUrl: null, error: null, }, { id: 'incheon-3008', name: '3008함 드론', shipName: '인천서 3008함', droneModel: 'DJI M30T', ip: '10.26.5.21', rtspUrl: 'rtsp://10.26.5.21:554/stream0', region: '인천', status: 'idle', hlsUrl: null, error: null, }, { id: 'mokpo-3015', name: '3015함 드론', shipName: '목포서 3015함', droneModel: 'DJI Mavic 3E', ip: '10.26.7.85', rtspUrl: 'rtsp://10.26.7.85:554/stream0', region: '목포', status: 'idle', hlsUrl: null, error: null, }, ]); } finally { setLoading(false); } }, []); useEffect(() => { loadStreams(); }, [loadStreams]); // Poll status every 3 seconds when any stream is starting useEffect(() => { const hasStarting = streams.some((s) => s.status === 'starting'); if (!hasStarting) return; const timer = setInterval(loadStreams, 3000); return () => clearInterval(timer); }, [streams, loadStreams]); const handleStartStream = async (id: string) => { try { await startDroneStreamApi(id); // Immediately update to 'starting' state setStreams((prev) => prev.map((s) => (s.id === id ? { ...s, status: 'starting' as const, error: null } : s)), ); // Poll for status update setTimeout(loadStreams, 2000); } catch { setStreams((prev) => prev.map((s) => s.id === id ? { ...s, status: 'error' as const, error: '스트림 시작 요청 실패' } : s, ), ); } }; const handleStopStream = async (id: string) => { try { await stopDroneStreamApi(id); setStreams((prev) => prev.map((s) => s.id === id ? { ...s, status: 'idle' as const, hlsUrl: null, error: null } : s, ), ); setActiveCells((prev) => prev.filter((c) => c.id !== id)); } catch { // ignore } }; const handleSelectStream = (stream: DroneStreamItem) => { setSelectedStream(stream); if (stream.status === 'streaming' && stream.hlsUrl) { if (gridMode === 1) { setActiveCells([stream]); } else { setActiveCells((prev) => { if (prev.length < gridMode && !prev.find((c) => c.id === stream.id)) return [...prev, stream]; return prev; }); } } }; const statusInfo = (status: string) => { switch (status) { case 'streaming': return { label: '송출중', color: 'var(--color-success)', bg: 'color-mix(in srgb, var(--color-success) 12%, transparent)', }; case 'starting': return { label: '연결중', color: 'var(--color-accent)', bg: 'rgba(6,182,212,.12)' }; case 'error': return { label: '오류', color: 'var(--color-danger)', bg: 'color-mix(in srgb, var(--color-danger) 12%, transparent)', }; default: return { label: '대기', color: 'var(--fg-disabled)', bg: 'rgba(255,255,255,.06)' }; } }; const gridCols = gridMode === 1 ? 1 : 2; const totalCells = gridMode; return (
{/* 좌측: 드론 스트림 목록 */}
{/* 헤더 */}
s.status === 'streaming') ? 'var(--color-success)' : 'var(--fg-disabled)', }} /> 실시간 드론 영상
ViewLink RTSP 스트림 · 내부망 전용
{/* 드론 스트림 카드 */}
{loading ? (
불러오는 중...
) : ( streams.map((stream) => { const si = statusInfo(stream.status); const isSelected = selectedStream?.id === stream.id; return (
handleSelectStream(stream)} className="px-3.5 py-3 border-b cursor-pointer transition-colors" style={{ borderColor: 'rgba(255,255,255,.04)', background: isSelected ? 'rgba(6,182,212,.08)' : 'transparent', }} >
🚁
{stream.shipName}{' '} ({stream.droneModel})
{stream.ip}
{si.label}
{stream.region} RTSP :554
{stream.error && (
{stream.error}
)} {/* 시작/중지 버튼 */}
{stream.status === 'idle' || stream.status === 'error' ? ( ) : ( )}
); }) )}
{/* 하단 안내 */}
RTSP 스트림은 해양경찰 내부망에서만 접속 가능합니다. ViewLink 프로그램과 연동됩니다.
{/* 중앙: 영상 뷰어 */}
{/* 툴바 */}
{selectedStream ? `🚁 ${selectedStream.shipName}` : '🚁 드론 스트림을 선택하세요'}
{selectedStream?.status === 'streaming' && (
LIVE
)} {selectedStream?.status === 'starting' && (
연결중
)}
{/* 분할 모드 */}
{[ { mode: 1, icon: '▣', label: '1화면' }, { mode: 4, icon: '⊞', label: '4분할' }, ].map((g) => ( ))}
{/* 드론 위치 지도 또는 영상 그리드 */} {showMap ? (
{streams.map((stream) => { const pos = DRONE_POSITIONS[stream.id]; if (!pos) return null; const statusColor = stream.status === 'streaming' ? 'var(--color-success)' : stream.status === 'starting' ? 'var(--color-accent)' : stream.status === 'error' ? 'var(--color-danger)' : 'var(--fg-disabled)'; return ( { e.originalEvent.stopPropagation(); setMapPopup(stream); }} >
{/* 연결선 (점선) */} {/* ── 함정: MarineTraffic 스타일 삼각형 (선수 방향 위) ── */} {/* 함정명 라벨 */} {stream.shipName.replace(/서 /, ' ')} {/* ── 드론: 쿼드콥터 아이콘 ── */} {/* 외곽 원 */} {/* X자 팔 */} {/* 프로펠러 4개 (회전 애니메이션) */} {/* 본체 */} {/* 카메라 렌즈 */} {/* 송출중 REC LED */} {stream.status === 'streaming' && ( )} {/* 드론 모델명 */} {stream.droneModel.split(' ').slice(-1)[0]}
); })} {/* 드론 클릭 팝업 */} {mapPopup && DRONE_POSITIONS[mapPopup.id] && ( setMapPopup(null)} closeOnClick={false} offset={36} className="cctv-dark-popup" >
🚁
{mapPopup.shipName}
{mapPopup.droneModel}
{mapPopup.ip} · {mapPopup.region}
● {statusInfo(mapPopup.status).label}
{mapPopup.status === 'idle' || mapPopup.status === 'error' ? ( ) : mapPopup.status === 'streaming' ? ( ) : (
연결 중...
)}
)}
{/* 지도 위 안내 배지 */}
🚁 드론 위치를 클릭하여 스트림을 시작하세요 ({streams.length}대)
) : (
{Array.from({ length: totalCells }).map((_, i) => { const stream = activeCells[i]; return (
{stream && stream.status === 'streaming' && stream.hlsUrl ? ( { playerRefs.current[i] = el; }} cameraNm={stream.shipName} streamUrl={stream.hlsUrl} sttsCd="LIVE" coordDc={`${stream.ip} · RTSP`} sourceNm="ViewLink" cellIndex={i} /> ) : stream && stream.status === 'starting' ? (
🚁
RTSP 스트림 연결 중...
{stream.ip}:554
) : stream && stream.status === 'error' ? (
⚠️
연결 실패
{stream.error}
) : (
{streams.length > 0 ? '스트림을 시작하고 선택하세요' : '드론 스트림을 선택하세요'}
)}
); })}
)} {/* 하단 정보 바 */}
선택: {selectedStream?.shipName ?? '–'}
IP:{' '} {selectedStream?.ip ?? '–'}
지역: {selectedStream?.region ?? '–'}
RTSP → HLS · ViewLink 연동
{/* 우측: 정보 패널 */}
{/* 헤더 */}
📋 스트림 정보
{selectedStream ? (
{[ ['함정명', selectedStream.shipName], ['드론명', selectedStream.name], ['기체모델', selectedStream.droneModel], ['IP 주소', selectedStream.ip], ['RTSP 포트', '554'], ['지역', selectedStream.region], ['프로토콜', 'RTSP → HLS'], ['상태', statusInfo(selectedStream.status).label], ].map(([k, v], i) => (
{k} {v}
))} {selectedStream.hlsUrl && (
HLS URL
{selectedStream.hlsUrl}
)}
) : (
드론 스트림을 선택하세요
)} {/* 연동 시스템 */}
🔗 연동 시스템
ViewLink 3.5 ● RTSP
FFmpeg 변환 RTSP→HLS
{/* 전체 상태 요약 */}
📊 스트림 현황
{[ { label: '전체', value: streams.length }, { label: '송출중', value: streams.filter((s) => s.status === 'streaming').length, }, { label: '연결중', value: streams.filter((s) => s.status === 'starting').length, }, { label: '오류', value: streams.filter((s) => s.status === 'error').length, }, ].map((item, i) => (
{item.label}
{item.value}
))}
); }