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 = { 'busan-1501': { ship: { lat: 35.0796, lon: 129.0756 }, drone: { lat: 35.1100, lon: 129.1100 } }, 'incheon-3008': { ship: { lat: 37.4541, lon: 126.5986 }, drone: { lat: 37.4800, lon: 126.5600 } }, 'mokpo-3015': { ship: { lat: 34.7780, lon: 126.3780 }, drone: { lat: 34.8050, lon: 126.4100 } }, } 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: 'rgba(34,197,94,.12)' } case 'starting': return { label: '연결중', color: 'var(--color-accent)', bg: 'rgba(6,182,212,.12)' } case 'error': return { label: '오류', color: 'var(--color-danger)', bg: 'rgba(239,68,68,.12)' } 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' ? '#22c55e' : stream.status === 'starting' ? '#06b6d4' : stream.status === 'error' ? '#ef4444' : '#94a3b8' 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, color: 'text-fg' }, { label: '송출중', value: streams.filter(s => s.status === 'streaming').length, color: 'text-color-success' }, { label: '연결중', value: streams.filter(s => s.status === 'starting').length, color: 'text-color-accent' }, { label: '오류', value: streams.filter(s => s.status === 'error').length, color: 'text-color-danger' }, ].map((item, i) => (
{item.label}
{item.value}
))}
) }