- 지도 스타일 상수를 mapStyles.ts로 추출 - useBaseMapStyle 훅 생성 (mapToggles 기반 스타일 반환) - 9개 탭 컴포넌트의 하드코딩 스타일을 공유 훅으로 교체 - 각 Map에 S57EncOverlay 추가 - 초기 mapToggles를 모두 false로 변경 (기본지도 표시)
496 lines
28 KiB
TypeScript
496 lines
28 KiB
TypeScript
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.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<DroneStreamItem[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [selectedStream, setSelectedStream] = useState<DroneStreamItem | null>(null)
|
||
const [gridMode, setGridMode] = useState(1)
|
||
const [activeCells, setActiveCells] = useState<DroneStreamItem[]>([])
|
||
const [mapPopup, setMapPopup] = useState<DroneStreamItem | null>(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 (
|
||
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
|
||
{/* 좌측: 드론 스트림 목록 */}
|
||
<div className="flex flex-col overflow-hidden bg-bg-surface border-r border-stroke w-[260px] min-w-[260px]">
|
||
{/* 헤더 */}
|
||
<div className="p-3 pb-2.5 border-b border-stroke shrink-0 bg-bg-elevated">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="text-xs font-bold text-fg font-korean flex items-center gap-1.5">
|
||
<span className="w-[7px] h-[7px] rounded-full inline-block" style={{ background: streams.some(s => s.status === 'streaming') ? 'var(--color-success)' : 'var(--fg-disabled)' }} />
|
||
실시간 드론 영상
|
||
</div>
|
||
<button
|
||
onClick={loadStreams}
|
||
className="px-2 py-0.5 text-[9px] font-korean bg-bg-card border border-stroke rounded text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors"
|
||
>새로고침</button>
|
||
</div>
|
||
<div className="text-[9px] text-fg-disabled font-korean">ViewLink RTSP 스트림 · 내부망 전용</div>
|
||
</div>
|
||
|
||
{/* 드론 스트림 카드 */}
|
||
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
|
||
{loading ? (
|
||
<div className="px-3.5 py-4 text-[11px] text-fg-disabled font-korean">불러오는 중...</div>
|
||
) : streams.map(stream => {
|
||
const si = statusInfo(stream.status)
|
||
const isSelected = selectedStream?.id === stream.id
|
||
return (
|
||
<div
|
||
key={stream.id}
|
||
onClick={() => 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',
|
||
}}
|
||
>
|
||
<div className="flex items-center justify-between mb-1.5">
|
||
<div className="flex items-center gap-2">
|
||
<div className="text-sm">🚁</div>
|
||
<div>
|
||
<div className="text-[11px] font-semibold text-fg font-korean">{stream.shipName} <span className="text-[9px] text-color-accent font-semibold">({stream.droneModel})</span></div>
|
||
<div className="text-[9px] text-fg-disabled font-mono">{stream.ip}</div>
|
||
</div>
|
||
</div>
|
||
<span
|
||
className="text-[8px] font-bold px-1.5 py-0.5 rounded-full"
|
||
style={{ background: si.bg, color: si.color }}
|
||
>{si.label}</span>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-[8px] text-fg-disabled font-korean px-1.5 py-0.5 rounded bg-bg-card">{stream.region}</span>
|
||
<span className="text-[8px] text-fg-disabled font-mono px-1.5 py-0.5 rounded bg-bg-card">RTSP :554</span>
|
||
</div>
|
||
|
||
{stream.error && (
|
||
<div className="mt-1.5 text-[8px] text-color-danger font-korean px-1.5 py-1 rounded bg-[rgba(239,68,68,.06)]">
|
||
{stream.error}
|
||
</div>
|
||
)}
|
||
|
||
{/* 시작/중지 버튼 */}
|
||
<div className="mt-2 flex gap-1.5">
|
||
{stream.status === 'idle' || stream.status === 'error' ? (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); handleStartStream(stream.id) }}
|
||
className="flex-1 px-2 py-1 text-[9px] font-bold font-korean rounded border cursor-pointer transition-colors"
|
||
style={{ background: 'rgba(34,197,94,.1)', borderColor: 'rgba(34,197,94,.3)', color: 'var(--color-success)' }}
|
||
>▶ 스트림 시작</button>
|
||
) : (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); handleStopStream(stream.id) }}
|
||
className="flex-1 px-2 py-1 text-[9px] font-bold font-korean rounded border cursor-pointer transition-colors"
|
||
style={{ background: 'rgba(239,68,68,.1)', borderColor: 'rgba(239,68,68,.3)', color: 'var(--color-danger)' }}
|
||
>■ 스트림 중지</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* 하단 안내 */}
|
||
<div className="px-3 py-2 border-t border-stroke bg-bg-elevated shrink-0">
|
||
<div className="text-[8px] text-fg-disabled font-korean leading-relaxed">
|
||
RTSP 스트림은 해양경찰 내부망에서만 접속 가능합니다.
|
||
ViewLink 프로그램과 연동됩니다.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 중앙: 영상 뷰어 */}
|
||
<div className="flex-1 flex flex-col overflow-hidden min-w-0 bg-bg-base">
|
||
{/* 툴바 */}
|
||
<div className="flex items-center justify-between px-4 py-2 border-b border-stroke bg-bg-elevated shrink-0 gap-2.5">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<div className="text-xs font-bold text-fg font-korean whitespace-nowrap overflow-hidden text-ellipsis">
|
||
{selectedStream ? `🚁 ${selectedStream.shipName}` : '🚁 드론 스트림을 선택하세요'}
|
||
</div>
|
||
{selectedStream?.status === 'streaming' && (
|
||
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0" style={{ background: 'rgba(34,197,94,.14)', border: '1px solid rgba(34,197,94,.35)', color: 'var(--color-success)' }}>
|
||
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--color-success)' }} />LIVE
|
||
</div>
|
||
)}
|
||
{selectedStream?.status === 'starting' && (
|
||
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0" style={{ background: 'rgba(6,182,212,.14)', border: '1px solid rgba(6,182,212,.35)', color: 'var(--color-accent)' }}>
|
||
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--color-accent)' }} />연결중
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-1.5 shrink-0">
|
||
{/* 분할 모드 */}
|
||
<div className="flex border border-stroke rounded-[5px] overflow-hidden">
|
||
{[
|
||
{ mode: 1, icon: '▣', label: '1화면' },
|
||
{ mode: 4, icon: '⊞', label: '4분할' },
|
||
].map(g => (
|
||
<button
|
||
key={g.mode}
|
||
onClick={() => { setGridMode(g.mode); setActiveCells(prev => prev.slice(0, g.mode)) }}
|
||
title={g.label}
|
||
className="px-2 py-1 text-[11px] cursor-pointer border-none transition-colors"
|
||
style={gridMode === g.mode
|
||
? { background: 'rgba(6,182,212,.15)', color: 'var(--color-accent)' }
|
||
: { background: 'var(--bg-card)', color: 'var(--fg-sub)' }
|
||
}
|
||
>{g.icon}</button>
|
||
))}
|
||
</div>
|
||
<button
|
||
onClick={() => playerRefs.current.forEach(r => r?.capture())}
|
||
className="px-2.5 py-1 bg-bg-card border border-stroke rounded-[5px] text-fg-sub text-[10px] font-semibold cursor-pointer font-korean hover:bg-bg-surface-hover transition-colors"
|
||
>📷 캡처</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 드론 위치 지도 또는 영상 그리드 */}
|
||
{showMap ? (
|
||
<div className="flex-1 overflow-hidden relative">
|
||
<Map
|
||
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 6.2 }}
|
||
mapStyle={currentMapStyle}
|
||
style={{ width: '100%', height: '100%' }}
|
||
attributionControl={false}
|
||
>
|
||
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||
{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 (
|
||
<Marker
|
||
key={stream.id}
|
||
longitude={(pos.ship.lon + pos.drone.lon) / 2}
|
||
latitude={(pos.ship.lat + pos.drone.lat) / 2}
|
||
anchor="center"
|
||
onClick={e => { e.originalEvent.stopPropagation(); setMapPopup(stream) }}
|
||
>
|
||
<div className="cursor-pointer group" title={stream.shipName}>
|
||
<svg width="130" height="85" viewBox="0 0 130 85" fill="none" className="drop-shadow-lg transition-transform group-hover:scale-105" style={{ overflow: 'visible' }}>
|
||
{/* 연결선 (점선) */}
|
||
<line x1="28" y1="52" x2="88" y2="30" stroke={statusColor} strokeWidth="1.2" strokeDasharray="4 3" opacity="0.5" />
|
||
|
||
{/* ── 함정: MarineTraffic 스타일 삼각형 (선수 방향 위) ── */}
|
||
<polygon points="28,38 18,58 38,58" fill={statusColor} opacity="0.85" />
|
||
<polygon points="28,38 18,58 38,58" fill="none" stroke="#fff" strokeWidth="0.8" opacity="0.5" />
|
||
{/* 함정명 라벨 */}
|
||
<rect x="3" y="61" width="50" height="13" rx="3" fill="rgba(0,0,0,.75)" />
|
||
<text x="28" y="70.5" textAnchor="middle" fill="#fff" fontSize="7" fontFamily="sans-serif" fontWeight="bold">{stream.shipName.replace(/서 /, ' ')}</text>
|
||
|
||
{/* ── 드론: 쿼드콥터 아이콘 ── */}
|
||
{/* 외곽 원 */}
|
||
<circle cx="88" cy="30" r="18" fill="rgba(10,14,24,.7)" stroke={statusColor} strokeWidth="1.5" />
|
||
{/* X자 팔 */}
|
||
<line x1="76" y1="18" x2="100" y2="42" stroke={statusColor} strokeWidth="1.2" opacity="0.5" />
|
||
<line x1="100" y1="18" x2="76" y2="42" stroke={statusColor} strokeWidth="1.2" opacity="0.5" />
|
||
{/* 프로펠러 4개 (회전 애니메이션) */}
|
||
<ellipse cx="76" cy="18" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
|
||
<animateTransform attributeName="transform" type="rotate" from="0 76 18" to="360 76 18" dur="1.5s" repeatCount="indefinite" />
|
||
</ellipse>
|
||
<ellipse cx="100" cy="18" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
|
||
<animateTransform attributeName="transform" type="rotate" from="0 100 18" to="-360 100 18" dur="1.5s" repeatCount="indefinite" />
|
||
</ellipse>
|
||
<ellipse cx="76" cy="42" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
|
||
<animateTransform attributeName="transform" type="rotate" from="0 76 42" to="-360 76 42" dur="1.5s" repeatCount="indefinite" />
|
||
</ellipse>
|
||
<ellipse cx="100" cy="42" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
|
||
<animateTransform attributeName="transform" type="rotate" from="0 100 42" to="360 100 42" dur="1.5s" repeatCount="indefinite" />
|
||
</ellipse>
|
||
{/* 본체 */}
|
||
<circle cx="88" cy="30" r="6" fill={statusColor} opacity="0.8" />
|
||
{/* 카메라 렌즈 */}
|
||
<circle cx="88" cy="30" r="3" fill="#fff" opacity="0.9" />
|
||
<circle cx="88" cy="30" r="1.5" fill={statusColor} />
|
||
{/* 송출중 REC LED */}
|
||
{stream.status === 'streaming' && (
|
||
<circle cx="100" cy="16" r="3" fill="#ef4444">
|
||
<animate attributeName="opacity" values="1;0.2;1" dur="1s" repeatCount="indefinite" />
|
||
</circle>
|
||
)}
|
||
{/* 드론 모델명 */}
|
||
<rect x="65" y="51" width="46" height="12" rx="3" fill="rgba(0,0,0,.75)" />
|
||
<text x="88" y="60" textAnchor="middle" fill={statusColor} fontSize="7" fontFamily="sans-serif" fontWeight="bold">{stream.droneModel.split(' ').slice(-1)[0]}</text>
|
||
</svg>
|
||
</div>
|
||
</Marker>
|
||
)
|
||
})}
|
||
{/* 드론 클릭 팝업 */}
|
||
{mapPopup && DRONE_POSITIONS[mapPopup.id] && (
|
||
<Popup
|
||
longitude={DRONE_POSITIONS[mapPopup.id].drone.lon}
|
||
latitude={DRONE_POSITIONS[mapPopup.id].drone.lat}
|
||
anchor="bottom"
|
||
onClose={() => setMapPopup(null)}
|
||
closeOnClick={false}
|
||
offset={36}
|
||
className="cctv-dark-popup"
|
||
>
|
||
<div className="p-2.5" style={{ minWidth: 170, background: 'var(--bg-card)', borderRadius: 6 }}>
|
||
<div className="flex items-center gap-1.5 mb-1">
|
||
<span className="text-sm">🚁</span>
|
||
<div className="text-[11px] font-bold text-fg">{mapPopup.shipName}</div>
|
||
</div>
|
||
<div className="text-[9px] text-fg-disabled mb-0.5">{mapPopup.droneModel}</div>
|
||
<div className="text-[8px] text-fg-disabled font-mono mb-2">{mapPopup.ip} · {mapPopup.region}</div>
|
||
<div className="flex items-center gap-1.5 mb-2">
|
||
<span className="text-[8px] font-bold px-1.5 py-px rounded-full"
|
||
style={{ background: statusInfo(mapPopup.status).bg, color: statusInfo(mapPopup.status).color }}
|
||
>● {statusInfo(mapPopup.status).label}</span>
|
||
</div>
|
||
{mapPopup.status === 'idle' || mapPopup.status === 'error' ? (
|
||
<button
|
||
onClick={() => { handleStartStream(mapPopup.id); handleSelectStream(mapPopup); setMapPopup(null) }}
|
||
className="w-full px-2 py-1.5 rounded text-[10px] font-bold cursor-pointer border transition-colors"
|
||
style={{ background: 'rgba(34,197,94,.15)', borderColor: 'rgba(34,197,94,.3)', color: '#4ade80' }}
|
||
>▶ 스트림 시작</button>
|
||
) : mapPopup.status === 'streaming' ? (
|
||
<button
|
||
onClick={() => { handleSelectStream(mapPopup); setMapPopup(null) }}
|
||
className="w-full px-2 py-1.5 rounded text-[10px] font-bold cursor-pointer border transition-colors"
|
||
style={{ background: 'rgba(6,182,212,.15)', borderColor: 'rgba(6,182,212,.3)', color: '#67e8f9' }}
|
||
>▶ 영상 보기</button>
|
||
) : (
|
||
<div className="text-[9px] text-color-accent font-korean text-center animate-pulse">연결 중...</div>
|
||
)}
|
||
</div>
|
||
</Popup>
|
||
)}
|
||
</Map>
|
||
{/* 지도 위 안내 배지 */}
|
||
<div className="absolute top-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full text-[10px] font-bold font-korean z-10"
|
||
style={{ background: 'rgba(0,0,0,.7)', color: 'rgba(255,255,255,.7)', backdropFilter: 'blur(4px)' }}>
|
||
🚁 드론 위치를 클릭하여 스트림을 시작하세요 ({streams.length}대)
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex-1 gap-0.5 p-0.5 overflow-hidden relative grid bg-black"
|
||
style={{
|
||
gridTemplateColumns: `repeat(${gridCols}, 1fr)`,
|
||
gridTemplateRows: `repeat(${gridCols}, 1fr)`,
|
||
}}>
|
||
{Array.from({ length: totalCells }).map((_, i) => {
|
||
const stream = activeCells[i]
|
||
return (
|
||
<div key={i} className="relative flex items-center justify-center overflow-hidden bg-bg-base" style={{ border: '1px solid var(--stroke-light)' }}>
|
||
{stream && stream.status === 'streaming' && stream.hlsUrl ? (
|
||
<CCTVPlayer
|
||
ref={el => { playerRefs.current[i] = el }}
|
||
cameraNm={stream.shipName}
|
||
streamUrl={stream.hlsUrl}
|
||
sttsCd="LIVE"
|
||
coordDc={`${stream.ip} · RTSP`}
|
||
sourceNm="ViewLink"
|
||
cellIndex={i}
|
||
/>
|
||
) : stream && stream.status === 'starting' ? (
|
||
<div className="flex flex-col items-center justify-center gap-2">
|
||
<div className="text-lg opacity-40 animate-pulse">🚁</div>
|
||
<div className="text-[10px] text-color-accent font-korean animate-pulse">RTSP 스트림 연결 중...</div>
|
||
<div className="text-[8px] text-fg-disabled font-mono">{stream.ip}:554</div>
|
||
</div>
|
||
) : stream && stream.status === 'error' ? (
|
||
<div className="flex flex-col items-center justify-center gap-2">
|
||
<div className="text-lg opacity-30">⚠️</div>
|
||
<div className="text-[10px] text-color-danger font-korean">연결 실패</div>
|
||
<div className="text-[8px] text-fg-disabled font-korean max-w-[200px] text-center">{stream.error}</div>
|
||
<button
|
||
onClick={() => handleStartStream(stream.id)}
|
||
className="mt-1 px-2.5 py-1 rounded text-[9px] font-korean bg-bg-card border border-stroke text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors"
|
||
>재시도</button>
|
||
</div>
|
||
) : (
|
||
<div className="text-[10px] text-fg-disabled font-korean opacity-40">
|
||
{streams.length > 0 ? '스트림을 시작하고 선택하세요' : '드론 스트림을 선택하세요'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* 하단 정보 바 */}
|
||
<div className="flex items-center gap-3.5 px-4 py-1.5 border-t border-stroke bg-bg-elevated shrink-0">
|
||
<div className="text-[10px] text-fg-disabled font-korean">선택: <b className="text-fg">{selectedStream?.shipName ?? '–'}</b></div>
|
||
<div className="text-[10px] text-fg-disabled font-korean">IP: <span className="text-color-accent font-mono text-[9px]">{selectedStream?.ip ?? '–'}</span></div>
|
||
<div className="text-[10px] text-fg-disabled font-korean">지역: <span className="text-fg-sub">{selectedStream?.region ?? '–'}</span></div>
|
||
<div className="ml-auto text-[9px] text-fg-disabled font-korean">RTSP → HLS · ViewLink 연동</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 우측: 정보 패널 */}
|
||
<div className="flex flex-col overflow-hidden bg-bg-surface border-l border-stroke w-[220px] min-w-[220px]">
|
||
{/* 헤더 */}
|
||
<div className="px-3 py-2 border-b border-stroke text-[11px] font-bold text-fg font-korean bg-bg-elevated shrink-0">
|
||
📋 스트림 정보
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto px-3 py-2.5" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
|
||
{selectedStream ? (
|
||
<div className="flex flex-col gap-1.5">
|
||
{[
|
||
['함정명', selectedStream.shipName],
|
||
['드론명', selectedStream.name],
|
||
['기체모델', selectedStream.droneModel],
|
||
['IP 주소', selectedStream.ip],
|
||
['RTSP 포트', '554'],
|
||
['지역', selectedStream.region],
|
||
['프로토콜', 'RTSP → HLS'],
|
||
['상태', statusInfo(selectedStream.status).label],
|
||
].map(([k, v], i) => (
|
||
<div key={i} className="flex justify-between px-2 py-1 bg-bg-base rounded text-[9px]">
|
||
<span className="text-fg-disabled font-korean">{k}</span>
|
||
<span className="font-mono text-fg">{v}</span>
|
||
</div>
|
||
))}
|
||
{selectedStream.hlsUrl && (
|
||
<div className="px-2 py-1 bg-bg-base rounded text-[8px]">
|
||
<div className="text-fg-disabled font-korean mb-0.5">HLS URL</div>
|
||
<div className="font-mono text-color-accent break-all">{selectedStream.hlsUrl}</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="text-[10px] text-fg-disabled font-korean">드론 스트림을 선택하세요</div>
|
||
)}
|
||
|
||
{/* 연동 시스템 */}
|
||
<div className="mt-3 pt-2.5 border-t border-stroke">
|
||
<div className="text-[10px] font-bold text-fg-sub font-korean mb-2">🔗 연동 시스템</div>
|
||
<div className="flex flex-col gap-1.5">
|
||
<div className="flex items-center justify-between px-2 py-1.5 bg-bg-card rounded-[5px]" style={{ border: '1px solid rgba(6,182,212,.2)' }}>
|
||
<span className="text-[9px] text-fg-sub font-korean">ViewLink 3.5</span>
|
||
<span className="text-[9px] font-bold" style={{ color: 'var(--color-accent)' }}>● RTSP</span>
|
||
</div>
|
||
<div className="flex items-center justify-between px-2 py-1.5 bg-bg-card rounded-[5px]" style={{ border: '1px solid rgba(59,130,246,.2)' }}>
|
||
<span className="text-[9px] text-fg-sub font-korean">FFmpeg 변환</span>
|
||
<span className="text-[9px] font-bold" style={{ color: 'var(--blue, #3b82f6)' }}>RTSP→HLS</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 전체 상태 요약 */}
|
||
<div className="mt-3 pt-2.5 border-t border-stroke">
|
||
<div className="text-[10px] font-bold text-fg-sub font-korean mb-2">📊 스트림 현황</div>
|
||
<div className="grid grid-cols-2 gap-1.5">
|
||
{[
|
||
{ 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) => (
|
||
<div key={i} className="px-2 py-1.5 bg-bg-base rounded text-center">
|
||
<div className="text-[8px] text-fg-disabled font-korean">{item.label}</div>
|
||
<div className={`text-sm font-bold font-mono ${item.color}`}>{item.value}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|