wing-ops/frontend/src/tabs/aerial/components/RealtimeDrone.tsx

826 lines
35 KiB
TypeScript
Raw Blame 히스토리

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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: '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 (
<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-caption 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-caption 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-caption 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-label-2 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-body-2">🚁</div>
<div>
<div className="text-label-2 font-normal text-fg font-korean">
{stream.shipName}{' '}
<span className="text-caption font-normal">({stream.droneModel})</span>
</div>
<div className="text-caption text-fg-disabled font-mono">{stream.ip}</div>
</div>
</div>
<span
className="text-caption 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-caption text-fg-disabled font-korean px-1.5 py-0.5 rounded bg-bg-card">
{stream.region}
</span>
<span className="text-caption 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-caption text-color-danger font-korean px-1.5 py-1 rounded bg-[color-mix(in srgb, var(--color-danger) 6%, transparent)]">
{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-caption font-bold font-korean rounded border cursor-pointer transition-colors bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-fg hover:bg-[rgba(6,182,212,0.15)]"
>
<span className="text-color-success"></span>
</button>
) : (
<button
onClick={(e) => {
e.stopPropagation();
handleStopStream(stream.id);
}}
className="flex-1 px-2 py-1 text-caption font-bold font-korean rounded border cursor-pointer transition-colors bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-fg hover:bg-[rgba(6,182,212,0.15)]"
>
<span className="text-color-danger"></span>
</button>
)}
</div>
</div>
);
})
)}
</div>
{/* 하단 안내 */}
<div className="px-3 py-2 border-t border-stroke bg-bg-elevated shrink-0">
<div className="text-caption 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-caption 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-caption font-bold shrink-0"
style={{
background: 'color-mix(in srgb, var(--color-success) 14%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-success) 35%, transparent)',
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-caption 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-label-2 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-label-1 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'
? 'var(--color-success)'
: stream.status === 'starting'
? 'var(--color-accent)'
: stream.status === 'error'
? 'var(--color-danger)'
: 'var(--fg-disabled)';
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="var(--static-white)"
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="var(--static-white)"
fontSize="7"
fontFamily="var(--font-korean)"
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="var(--static-white)" 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="var(--color-danger)">
<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="var(--font-korean)"
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-body-2">🚁</span>
<div className="text-label-2 font-bold text-fg">{mapPopup.shipName}</div>
</div>
<div className="text-caption text-fg-disabled mb-0.5">
{mapPopup.droneModel}
</div>
<div className="text-caption 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-caption 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-label-1 font-bold cursor-pointer border transition-colors"
style={{
background: 'color-mix(in srgb, var(--color-success) 15%, transparent)',
borderColor: 'color-mix(in srgb, var(--color-success) 30%, transparent)',
color: 'var(--color-success)',
}}
>
</button>
) : mapPopup.status === 'streaming' ? (
<button
onClick={() => {
handleSelectStream(mapPopup);
setMapPopup(null);
}}
className="w-full px-2 py-1.5 rounded text-label-1 font-bold cursor-pointer border transition-colors"
style={{
background: 'rgba(6,182,212,.15)',
borderColor: 'rgba(6,182,212,.3)',
color: 'var(--color-accent)',
}}
>
</button>
) : (
<div className="text-caption 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-label-1 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-label-1 text-color-accent font-korean animate-pulse">
RTSP ...
</div>
<div className="text-caption 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-label-1 text-color-danger font-korean"> </div>
<div className="text-caption 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-caption 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-label-1 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-label-1 text-fg-disabled font-korean">
: <b className="text-fg">{selectedStream?.shipName ?? ''}</b>
</div>
<div className="text-label-1 text-fg-disabled font-korean">
IP:{' '}
<span className="text-color-accent font-mono text-caption">
{selectedStream?.ip ?? ''}
</span>
</div>
<div className="text-label-1 text-fg-disabled font-korean">
: <span className="text-fg-sub">{selectedStream?.region ?? ''}</span>
</div>
<div className="ml-auto text-caption 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-label-2 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-caption"
>
<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-caption">
<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-label-1 text-fg-disabled font-korean">
</div>
)}
{/* 연동 시스템 */}
<div className="mt-3 pt-2.5 border-t border-stroke">
<div className="text-label-1 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-caption text-fg-sub font-korean">ViewLink 3.5</span>
<span className="text-caption text-fg"> 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(6,182,212,.2)' }}
>
<span className="text-caption text-fg-sub font-korean">FFmpeg </span>
<span className="text-caption text-fg">RTSPHLS</span>
</div>
</div>
</div>
{/* 전체 상태 요약 */}
<div className="mt-3 pt-2.5 border-t border-stroke">
<div className="text-label-1 font-bold text-fg-sub font-korean mb-2">
📊
</div>
<div className="grid grid-cols-2 gap-1.5">
{[
{ 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) => (
<div key={i} className="px-2 py-1.5 bg-bg-base rounded text-center">
<div className="text-caption text-fg-disabled font-korean">{item.label}</div>
<div className="text-body-2 font-mono text-fg">{item.value}</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}