Files
GhiVideo_v4/client/src/components/player/VideoPlayer.tsx
T
b23042andClaude Fable 5 84f4921826 제주 드론 도로영상 지원: DJI 로그 어댑터·분할영상 연속재생·카메라 자동감지·오버레이 개선
- 제주 DJI 시간기반 비행로그(CSV) 파싱 어댑터: 세그먼트 창 정렬, 가상 fps 환산
- 분할 영상(이름 오름차순) 연속재생 + 좌상단 영상목록 콤보(선택 재생), 세그먼트별 드론 로그 재정렬
- KML 다중 파일 병합 파싱: STA 체이니지 측점(71개) + 지장물 신포맷(타입/이름/텍스트박스_색상)
- 시설물 라벨: 지정색 배경 박스 + 흰 글자, 팝업 중앙 정렬, 호버 구간 깜빡임 수정
- 카메라 자동감지(djmd: ZenmuseP1, focal 29.9mm) + <영상명>.camera.json PC 저장/폴더 자동 적용
- Yaw 자동추정(GPS 진행방위 기반) + 라벨 드래그 Yaw 역산 모드
- 스테이션바: GPS 이동거리 진행도(호버 시 정지), 노선밖 거리 표기, 원거리 구조물 마크 제외
- 서버: /api/camera 라우트, ecosystem 포트 54000·제주 데이터 경로
- docs/history: 작업 이력 21건

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:58:49 +09:00

666 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 React, { useRef, useImperativeHandle, forwardRef, useState, useEffect, useMemo } from 'react';
import StationOverlay from '../overlay/StationOverlay';
import RoutePanel from '../overlay/RoutePanel';
import RouteInfoOverlay from '../overlay/RouteInfoOverlay';
import 'video.js/dist/video-js.css';
import { useVideoPlayer } from '../../hooks/useVideoPlayer';
import { useFrameStep } from '../../hooks/useFrameStep';
import { useKeyboard } from '../../hooks/useKeyboard';
import { usePlayerStore } from '../../store/playerStore';
import { useGeoStore } from '../../store/geoStore';
import { useSettingsStore, KNOWN_GRADES } from '../../store/settingsStore';
import { captureFrame, downloadDataUrl } from '../../utils/frameCapture';
import { secondsToTimecode, secondsToFrame } from '../../utils/timecode';
import { useCaptureStore } from '../../store/captureStore';
import HlsConversionStatus from './HlsConversionStatus';
import { StationBar } from '../../stationbar/StationBar';
/** 드롭된 디렉토리/파일 엔트리에서 모든 파일을 재귀 수집 (폴더 드래그&드롭 지원). */
function collectDropEntry(entry: FileSystemEntry, out: File[]): Promise<void> {
return new Promise((resolve) => {
if (entry.isFile) {
(entry as FileSystemFileEntry).file((f) => { out.push(f); resolve(); }, () => resolve());
} else if (entry.isDirectory) {
const reader = (entry as FileSystemDirectoryEntry).createReader();
const readBatch = () => {
reader.readEntries(async (ents) => {
if (!ents.length) { resolve(); return; } // 모든 배치 소진
await Promise.all(ents.map((en) => collectDropEntry(en, out)));
readBatch();
}, () => resolve());
};
readBatch();
} else {
resolve();
}
});
}
export interface VideoPlayerHandle {
loadLocalFile: (file: File) => void;
loadServerStream: (videoId: string, filename: string) => void | Promise<void>;
seekTo: (time: number) => void;
getVideoElement: () => HTMLVideoElement | null;
}
/** 초 → "m:ss" (재생목록 길이 표시용). 미상이면 "--:--". */
function fmtDur(d: number | null | undefined): string {
if (d == null || !isFinite(d)) return '--:--';
return `${Math.floor(d / 60)}:${String(Math.floor(d % 60)).padStart(2, '0')}`;
}
interface VideoPlayerProps {
onAddMemo: (time: number) => void;
onToggleHelp?: () => void;
}
const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
function VideoPlayer({ onAddMemo, onToggleHelp }, ref) {
const containerRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const { playerRef, loadLocalFile, loadServerStream, switchToHls, getVideoElement } =
useVideoPlayer(containerRef);
const { stepForward, stepBackward } = useFrameStep(playerRef);
const { currentTime, duration, playing, source, playbackRate, videoReady, videoWidth, videoHeight } = usePlayerStore();
// 커서 매끄러운 이동:
// - 라이브 시간(smoothTimeRef): 매 프레임 '단조' 벽시계 보간. StationBar가 ref로 읽어
// 커서/진행바를 직접(transform) 갱신 → React 60fps 리렌더 없이 매끄럽게.
// - state(smoothTime): 배지 숫자·색 변경용으로만 throttle(≈10fps) 갱신.
const [smoothTime, setSmoothTime] = useState(0);
const smoothTimeRef = useRef(0);
const anchorRef = useRef({ media: 0, wall: 0 });
const lastSetRef = useRef(0);
useEffect(() => {
let raf = 0;
// 시크(클릭/드래그 이동) 시 커서를 즉시 그 위치로 재동기화.
// (단조 보간은 앵커보다 앞쪽으로의 뒤로가기 시크를 무시하므로 별도 처리 필요)
const onSeeked = (): void => {
const p = playerRef.current;
if (!p || p.isDisposed()) return;
const t = p.currentTime() ?? 0;
anchorRef.current = { media: t, wall: performance.now() };
smoothTimeRef.current = t;
lastSetRef.current = t;
setSmoothTime(t);
};
playerRef.current?.on('seeked', onSeeked);
const tick = (): void => {
const p = playerRef.current;
if (p && !p.isDisposed()) {
const dur = p.duration() ?? 0;
let t: number;
if (p.paused()) {
t = p.currentTime() ?? 0;
anchorRef.current = { media: t, wall: performance.now() };
} else {
const a = anchorRef.current;
const rate = p.playbackRate() ?? 1;
let est = a.media + ((performance.now() - a.wall) / 1000) * rate;
const real = p.currentTime() ?? 0;
// 단조: 작은 역행은 무시(흔들림 방지). 뒤처짐(real이 앞섬) 또는 시크(뒤로)만 재동기화.
if (real - est > 0.3 || real < a.media - 0.3) {
est = real;
anchorRef.current = { media: real, wall: performance.now() };
}
t = dur > 0 ? Math.min(est, dur) : est;
}
smoothTimeRef.current = t;
if (Math.abs(t - lastSetRef.current) >= 0.1) {
lastSetRef.current = t;
setSmoothTime(t);
}
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
playerRef.current?.off('seeked', onSeeked);
};
}, [playerRef, source]);
const loadFromFolder = useGeoStore((s) => s.loadFromFolder);
const geoLoaded = useGeoStore((s) => s.loaded);
// 드론 정보 — 하단바에 GPS/고도 항상 표시.
const storeFrames = useGeoStore((s) => s.frames);
// 분할 영상 재생목록 — 폴더 로드 시 이름 오름차순. 목록 클릭으로 세그먼트 직접 선택 재생.
const videoFiles = useGeoStore((s) => s.videoFiles);
const videoDurations = useGeoStore((s) => s.videoDurations);
const videoIndex = useGeoStore((s) => s.videoIndex);
// 영상목록 콤보(좌상단 타이틀 아래) 펼침 상태 — 기본 접힘.
const [showPlaylist, setShowPlaylist] = useState(false);
// 시설등급(시설종별) 표시 필터 — 재생바/패널이 구독하는 설정 스토어.
const gradeFilter = useSettingsStore((s) => s.gradeFilter);
const setGradeFilter = useSettingsStore((s) => s.setGradeFilter);
const poiOverlapExclude = useSettingsStore((s) => s.poiOverlapExclude);
const setPoiOverlapExclude = useSettingsStore((s) => s.setPoiOverlapExclude);
// 선형(중심선)·드론 궤적 영상 오버레이 토글 — StationOverlay 와 공유(설정 스토어).
const showCenterline = useSettingsStore((s) => s.showCenterline);
const setShowCenterline = useSettingsStore((s) => s.setShowCenterline);
const showDronePath = useSettingsStore((s) => s.showDronePath);
const setShowDronePath = useSettingsStore((s) => s.setShowDronePath);
const showRoutePanel = useSettingsStore((s) => s.showRoutePanel);
const setShowRoutePanel = useSettingsStore((s) => s.setShowRoutePanel);
// 하단 도구 패널: UI에서 숨김(코드는 보존). 다시 표시하려면 true 로.
const SHOW_TOOLBAR = false;
// 폴더 선택: 지리정보 파싱 + 영상 재생
const handleSelectFolder = async (files: File[]) => {
if (!files.length) return;
try {
const videoFile = await loadFromFolder(files);
if (videoFile) loadLocalFile(videoFile);
else console.warn('[geo] 폴더에 영상 파일(mp4/webm)이 없습니다 — 지리정보만 로드');
// KMZ(POI·구조물 원본) 필수 — 측점/드론은 있는데 KMZ만 빠진 경우 = 데이터 누락.
const { kmzMissing, stations, frames } = useGeoStore.getState();
if (kmzMissing && (stations.length > 0 || frames.length > 0)) {
alert(
'KMZ(POI·구조물 원본)가 폴더에 없습니다.\n' +
'POI·구조물이 표시되지 않습니다. KMZ를 포함해 데이터를 재구축·전달하세요.\n' +
'(측점·드론 정보는 정상 로드되었습니다.)',
);
}
} catch (err) {
console.error('[geo] 폴더 로드 실패', err);
}
};
// 재생목록에서 세그먼트 선택 — 같은 항목이면 처음부터 재생, 다른 항목이면
// 드론 로그를 해당 구간으로 재정렬(loadSegment)한 뒤 전환 재생.
const handleSelectSegment = async (i: number): Promise<void> => {
const geo = useGeoStore.getState();
if (usePlayerStore.getState().source?.kind !== 'local') return;
if (i === geo.videoIndex) {
playerRef.current?.currentTime(0);
} else {
const file = await geo.loadSegment(i);
if (!file) return;
loadLocalFile(file);
}
const p = playerRef.current?.play();
if (p && typeof p.catch === 'function') p.catch(() => {});
};
// 분할 영상 연속재생 — 영상이 끝나면 같은 폴더의 다음 영상(이름 오름차순)을 자동 재생.
// 드론 로그(시간 기반)는 loadSegment 가 누적 오프셋으로 재정렬해 궤적 정합을 유지한다.
useEffect(() => {
const player = playerRef.current;
if (!player) return;
const onEnded = (): void => {
void (async () => {
const geo = useGeoStore.getState();
if (!geo.loaded || geo.videoFiles.length < 2) return;
// 폴더 기반 로컬 재생에서만 동작 (서버 스트림/단일 파일은 대상 아님)
if (usePlayerStore.getState().source?.kind !== 'local') return;
const next = geo.videoIndex + 1;
if (next >= geo.videoFiles.length) return;
const file = await geo.loadSegment(next);
if (!file) return;
loadLocalFile(file);
// ended 직후는 paused 상태라 loadLocalFile 의 이어재생 로직이 안 걸림 → 직접 재생.
const p = player.play();
if (p && typeof p.catch === 'function') p.catch(() => {});
})();
};
player.on('ended', onEnded);
return () => { player.off('ended', onEnded); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playerRef, loadLocalFile]);
// Expose methods to parent via ref
useImperativeHandle(ref, () => ({
loadLocalFile,
loadServerStream,
seekTo: (time: number) => {
playerRef.current?.currentTime(time);
},
getVideoElement,
}));
const addCapture = useCaptureStore((s) => s.addCapture);
const handleCaptureFrame = () => {
const video = getVideoElement();
if (!video) return;
const dataUrl = captureFrame(video);
if (!dataUrl) return;
const filename = `frame_${secondsToTimecode(currentTime).replace(/[:.]/g, '-')}.jpg`;
downloadDataUrl(dataUrl, filename);
addCapture({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
dataUrl,
time: currentTime,
filename,
createdAt: Date.now(),
});
};
const handleAddMemo = () => onAddMemo(currentTime);
const [showStations, setShowStations] = useState(true);
// 영상제어 토글: 카메라 파라미터·프레임상태·배속 표시 on/off
const [showVideoControls, setShowVideoControls] = useState(true);
// 좌하단 그룹을 재생바 위에 두기 위해 StationBar 높이를 동적 측정.
const barWrapRef = useRef<HTMLDivElement>(null);
const [barHeight, setBarHeight] = useState(0);
useEffect(() => {
const el = barWrapRef.current;
if (!el) return;
const update = (): void => setBarHeight(el.offsetHeight);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, [geoLoaded]);
// 노선 배너(429.5×65 @1920 비율) 높이만큼 카메라 파라미터를 아래로.
const [stageWidth, setStageWidth] = useState(0);
useEffect(() => {
const el = wrapperRef.current;
if (!el) return;
const update = (): void => setStageWidth(el.clientWidth);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
const paramTop = Math.round((stageWidth / 1920) * 65) + 10;
const [showUtilBar, setShowUtilBar] = useState(false);
const handleTogglePlay = (): void => {
const p = playerRef.current;
if (!p) return;
if (playing) p.pause();
else void p.play();
};
const handleStop = (): void => {
const p = playerRef.current;
if (!p) return;
p.pause();
p.currentTime(0);
};
const handleSeek = (t: number): void => {
playerRef.current?.currentTime(t);
};
useKeyboard({
playerRef,
onStepForward: stepForward,
onStepBackward: stepBackward,
onCaptureFrame: handleCaptureFrame,
onAddMemo: handleAddMemo,
onToggleHelp,
containerRef: wrapperRef,
});
// 드래그&드롭 — 폴더(영상+측점/POI) 또는 단일 영상 파일.
// 폴더는 dataTransfer.files 가 비어 있으므로 webkitGetAsEntry 로 디렉토리를 재귀 순회한다.
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
// await 후엔 dataTransfer 가 무효화될 수 있어 동기적으로 먼저 캡처.
const entries: FileSystemEntry[] = [];
const dt = e.dataTransfer;
if (dt.items) {
for (let i = 0; i < dt.items.length; i++) {
const en = dt.items[i].webkitGetAsEntry?.();
if (en) entries.push(en);
}
}
const flatFiles = Array.from(dt.files);
void (async () => {
if (entries.length) {
const out: File[] = [];
await Promise.all(entries.map((en) => collectDropEntry(en, out)));
if (out.length) { handleSelectFolder(out); return; }
}
if (flatFiles.length > 1) { handleSelectFolder(flatFiles); return; }
const file = flatFiles[0];
if (file?.type.startsWith('video/')) loadLocalFile(file);
})();
};
// 영상 실제 fps — 데이터(드론 CSV 마지막 프레임 번호) ÷ 영상 길이(초) 로 자동 산출 후
// 표준 fps(24/25/29.97/30/50/60 등) 중 가까운 값에 스냅. 영상마다 fps 가 달라도 자동 대응.
// (드론 CSV 엔 시간이 없고 frame_cnt 만 있어, 영상 length 와 결합해야 fps 를 알 수 있다.)
// 데이터/영상길이 미확보 시 29.97 폴백. VFC 자동감지는 31fps 오감지가 있어 사용 안 함.
const effectiveFps = useMemo(() => {
const FALLBACK = 30000 / 1001;
if (!storeFrames.length || !duration || duration <= 0) return FALLBACK;
let maxF = 0;
for (const f of storeFrames) if (f.frame > maxF) maxF = f.frame;
if (maxF <= 0) return FALLBACK;
const raw = maxF / duration; // 마지막 프레임 번호 ÷ 영상 길이(초)
const STD = [23.976, 24, 25, 29.97, 30, 50, 59.94, 60];
let best = STD[0], bd = Math.abs(raw - STD[0]);
for (const s of STD) { const d = Math.abs(raw - s); if (d < bd) { bd = d; best = s; } }
return bd <= best * 0.1 ? best : raw; // 표준값 ±10% 이내면 스냅, 아니면 원시값
}, [storeFrames, duration]);
const frame = secondsToFrame(currentTime, effectiveFps);
const videoId = source?.kind === 'server' ? source.videoId : null;
// 드론 정보(측점진단) — 현재 프레임에 가장 가까운 드론 프레임의 GPS/고도만 표시.
// 드론 GPS·고도 HUD 는 항상 표시 → 토글 없이 프레임만 있으면 계산.
const stationDiag = useMemo(() => {
if (!storeFrames.length) return null;
let best = storeFrames[0], bd = Math.abs(storeFrames[0].frame - frame);
for (const df of storeFrames) {
const d = Math.abs(df.frame - frame);
if (d < bd) { bd = d; best = df; }
}
return { f: best };
}, [storeFrames, frame]);
return (
<div
ref={wrapperRef}
className="relative bg-black w-full h-full"
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
>
{/* 영상 영역 — 컨테이너 전체를 채우는 relative 래퍼 (사이니지: 화면 가득) */}
<div className="relative w-full h-full">
{/* Video.js container — 영상이 영역을 꽉 채우는 베이스 레이어 (object-fit:cover) */}
{/* 영상 클릭 = 재생/일시정지 토글 (컨트롤바 숨김 상태) */}
<div
data-vjs-player
ref={containerRef}
className="absolute inset-0 w-full h-full"
style={{ cursor: source ? 'pointer' : 'default' }}
onClick={() => {
if (source) handleTogglePlay();
}}
/>
{/* 노선 정보 배너 — 영상 좌상단 (route.json routeInfo) */}
<RouteInfoOverlay />
{/* 좌하단 그룹(재생바 위) — 한 줄: 영상제어 → 배속 → 프레임정보 → 겹침제외 → 시설등급 */}
{source && (
<div
className="absolute left-2 z-30 flex items-center gap-2 flex-wrap pointer-events-auto"
style={{ bottom: (barHeight || 130) + 8 }}
>
{/* 영상제어 토글 — 버튼만, 고정폭(가장 긴 'OFF' 기준이라 ON/OFF로 폭이 안 변함) */}
<button
type="button"
onClick={() => setShowVideoControls((v) => !v)}
className={`text-xs px-2 py-1 rounded border font-semibold text-center min-w-[96px] ${showVideoControls ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 border-gray-600 text-gray-300'}`}
>영상제어 {showVideoControls ? 'ON' : 'OFF'}</button>
{showVideoControls && (
<>
{/* 배속 패널 (영상제어 우측) */}
<div className="flex items-center gap-1 bg-black/70 px-2 py-1 rounded">
<span className="text-gray-400 text-xs">배속</span>
{[0.5, 1, 1.5, 2, 3, 4].map((r) => (
<button
key={r}
type="button"
onClick={() => playerRef.current?.playbackRate(r)}
className={`text-xs px-1.5 py-0.5 rounded ${
Math.abs(playbackRate - r) < 0.01
? 'bg-amber-400 text-black'
: 'bg-gray-700 text-gray-200 hover:bg-gray-600'
}`}
>
{r}x
</button>
))}
</div>
{/* 영상 프레임 표시 패널 — 프레임 번호를 고정폭(6ch)으로 묶어 자릿수가 늘어도 폭 불변 */}
<span className="bg-black/70 text-gray-200 text-xs px-2 py-1 rounded font-mono whitespace-nowrap">
{secondsToTimecode(currentTime)} | F<span className="inline-block text-left" style={{ minWidth: '6ch' }}>{frame}</span> | {effectiveFps.toFixed(2)}fps
</span>
{geoLoaded && (
<>
{/* 좌측 패널 토글 — 선형 왼편, 동일 폭(min-w-[96px]) */}
<button
type="button"
onClick={() => setShowRoutePanel(!showRoutePanel)}
title="좌측 노선 패널 표시/숨김"
className={`text-xs px-2 py-1 rounded border text-center min-w-[96px] ${showRoutePanel ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 border-gray-600 text-gray-300'}`}
>좌측 패널 {showRoutePanel ? 'ON' : 'OFF'}</button>
{/* 선형(중심선) 토글 — 겹침제외와 동일 폭(min-w-[96px]) */}
<button
type="button"
onClick={() => setShowCenterline(!showCenterline)}
title="선형(중심선) 표시/숨김"
className={`text-xs px-2 py-1 rounded border text-center min-w-[96px] ${showCenterline ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 border-gray-600 text-gray-300'}`}
>선형 {showCenterline ? 'ON' : 'OFF'}</button>
{/* 드론 궤적 토글 — 겹침제외와 동일 폭(min-w-[96px]) */}
<button
type="button"
onClick={() => setShowDronePath(!showDronePath)}
title="드론 궤적 표시/숨김"
className={`text-xs px-2 py-1 rounded border text-center min-w-[96px] ${showDronePath ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 border-gray-600 text-gray-300'}`}
>드론궤적 {showDronePath ? 'ON' : 'OFF'}</button>
{/* 드론 위치정보 — 드론궤적 버튼 바로 우측에 붙임. GPS·고도 항상 표시. */}
{stationDiag && (
<div className="bg-black/70 px-2 py-1 rounded font-mono text-xs text-gray-200 whitespace-nowrap">
GPS {stationDiag.f.lat.toFixed(6)}, {stationDiag.f.lon.toFixed(6)}
<span className="text-gray-400"> · 절대고도 {stationDiag.f.altitude.toFixed(1)}m</span>
</div>
)}
{/* 겹침제외 토글 — UI 숨김(기능/상태는 보존, 기본 ON). 다시 보이려면 주석 해제. */}
{/* <button
type="button"
onClick={() => setPoiOverlapExclude(!poiOverlapExclude)}
title="ON=겹친 POI 숨김 / OFF=모든 POI 표시"
className={`text-xs px-2 py-1 rounded border text-center min-w-[96px] ${poiOverlapExclude ? 'bg-blue-600/80 border-blue-400 text-white' : 'bg-black/70 border-gray-600 text-gray-300'}`}
>겹침제외 {poiOverlapExclude ? 'ON' : 'OFF'}</button> */}
{/* 시설등급 패널 */}
<div className="flex items-center gap-2 bg-black/70 px-2 py-1 rounded">
<span className="text-gray-400 text-xs">시설등급</span>
{KNOWN_GRADES.map((g) => (
<label
key={g}
className="flex items-center gap-1 text-xs text-gray-200 cursor-pointer select-none"
>
<input
type="checkbox"
className="accent-amber-400"
checked={gradeFilter[g] ?? false}
onChange={(e) => setGradeFilter(g, e.target.checked)}
/>
{g}
</label>
))}
</div>
</>
)}
</>
)}
</div>
)}
{/* 루트 패널 미니맵 — 위(배너+카메라파라미터)·아래(배속/토글/재생바) 침범 방지 */}
<RoutePanel
currentTime={currentTime}
visible={showStations && showRoutePanel}
onSeek={(time) => playerRef.current?.currentTime(time)}
topPx={paramTop + 40}
bottomPx={(barHeight || 130) + 90}
/>
</div>
{/* 영상 목록 콤보 — 좌상단 타이틀(노선 배너) 아래. 버튼 클릭 시 목록이 아래로 펼쳐지고
항목 선택 시 해당 영상으로 즉시 전환(드론 로그도 세그먼트에 맞춰 자동 재정렬).
현재 재생 항목 재선택 시 처음부터 재생. */}
{source?.kind === 'local' && videoFiles.length > 0 && (
<div className="absolute left-2 z-40 w-80 pointer-events-auto" style={{ top: paramTop }}>
<button
type="button"
onClick={() => setShowPlaylist((v) => !v)}
title="폴더 영상 목록 펼치기/접기"
className={`w-full flex items-center gap-1.5 px-2 py-1 rounded border text-xs text-left ${
showPlaylist
? 'bg-amber-400 border-amber-300 text-black font-semibold'
: 'bg-black/70 border-gray-600 text-gray-200 hover:bg-black/90'
}`}
>
<span className="shrink-0"></span>
<span className="flex-1 truncate">{videoFiles[videoIndex]?.name ?? ''}</span>
<span className={`shrink-0 font-mono ${showPlaylist ? 'text-black/70' : 'text-gray-400'}`}>
{videoIndex + 1}/{videoFiles.length} {showPlaylist ? '▲' : '▼'}
</span>
</button>
{showPlaylist && (
<ul className="mt-1 max-h-56 overflow-y-auto bg-black/85 border border-gray-600 rounded-md">
{videoFiles.map((f, i) => (
<li key={`${i}-${f.name}`}>
<button
type="button"
onClick={() => { setShowPlaylist(false); void handleSelectSegment(i); }}
title={i === videoIndex ? `${f.name} (재선택 시 처음부터)` : f.name}
className={`w-full flex items-center gap-1.5 px-2 py-1 text-xs text-left ${
i === videoIndex
? 'bg-amber-400/90 text-black font-semibold'
: 'text-gray-200 hover:bg-gray-700/80'
}`}
>
<span className="w-4 shrink-0 text-center">{i === videoIndex ? '▶' : i + 1}</span>
<span className="flex-1 truncate">{f.name}</span>
<span className={`shrink-0 font-mono ${i === videoIndex ? 'text-black/70' : 'text-gray-400'}`}>
{fmtDur(videoDurations[i])}
</span>
</button>
</li>
))}
</ul>
)}
</div>
)}
{/* Empty state placeholder — 가운데 반투명 폴더 선택 */}
{!source && (
<div className="absolute inset-0 flex flex-col items-center justify-center text-gray-400 pointer-events-none select-none" style={{ minHeight: '240px' }}>
<div className="text-5xl mb-3 opacity-60">&#9654;</div>
<p className="text-lg">동영상 폴더를 드래그하거나 선택하세요</p>
<p className="text-sm mt-1 mb-6">영상 + 측점/POI 데이터가 함께 로드됩니다</p>
<label className="pointer-events-auto cursor-pointer bg-emerald-500/20 hover:bg-emerald-500/40 backdrop-blur-sm border border-emerald-300/40 text-white text-base font-medium px-7 py-3 rounded-xl shadow-lg transition-colors">
폴더 선택
<input
type="file"
className="hidden"
webkitdirectory=""
directory=""
multiple
onChange={(e) => {
const files = e.target.files;
if (files?.length) void handleSelectFolder(Array.from(files));
}}
/>
</label>
</div>
)}
{/* 측점 오버레이 */}
<StationOverlay
currentFrame={frame}
currentTime={currentTime}
timeRef={smoothTimeRef}
fps={effectiveFps}
visible={showStations}
videoReady={videoReady}
videoWidth={videoWidth}
videoHeight={videoHeight}
onTogglePlay={handleTogglePlay}
showPanel={showVideoControls && !!source}
topPx={paramTop}
barHeight={barHeight}
/>
{/* 측점 기반 재생 바 — 영상 하단에 오버레이로 앵커 (폴더 로드 후) */}
{geoLoaded && (
<div ref={barWrapRef} className="absolute bottom-0 left-0 right-0 z-20">
<StationBar
currentTime={smoothTime}
timeRef={smoothTimeRef}
duration={duration}
playing={playing}
onTogglePlay={handleTogglePlay}
onStop={handleStop}
onCapture={handleCaptureFrame}
onSeek={handleSeek}
showStations={showStations}
onToggleStations={() => setShowStations((v) => !v)}
/>
</div>
)}
{/* abcVideo 전용 유틸 행 (파일/프레임이동/HLS) — UI 숨김(코드 보존, SHOW_TOOLBAR 로 제어) */}
{SHOW_TOOLBAR && (
<div className="bg-gray-900 border-t border-gray-800">
<button
type="button"
onClick={() => setShowUtilBar((v) => !v)}
className="w-full flex items-center gap-1.5 px-2 py-1 text-xs text-gray-400 hover:text-white"
>
<span className={`inline-block transition-transform ${showUtilBar ? 'rotate-90' : ''}`}>&#9656;</span>
도구 {showUtilBar ? '접기' : '펼치기'}
</button>
{showUtilBar && (
<div className="flex items-center gap-2 px-2 pb-2 flex-wrap">
<label className="cursor-pointer bg-emerald-600 hover:bg-emerald-700 text-white text-sm px-3 py-1.5 rounded">
폴더 선택
<input
type="file"
className="hidden"
webkitdirectory=""
directory=""
multiple
onChange={(e) => {
const files = e.target.files;
if (files?.length) void handleSelectFolder(Array.from(files));
}}
/>
</label>
{/* 프레임 직접 이동 */}
<form
onSubmit={e => {
e.preventDefault();
const input = (e.currentTarget.elements.namedItem('frameInput') as HTMLInputElement);
const frameNum = parseInt(input.value, 10);
if (!isNaN(frameNum)) {
playerRef.current?.currentTime(frameNum / effectiveFps);
}
input.blur();
}}
className="flex items-center gap-1"
>
<span className="text-gray-500 text-xs">F</span>
<input
name="frameInput"
type="number"
min={0}
step={1}
placeholder="프레임"
className="w-20 bg-black/60 border border-gray-600 rounded px-1.5 py-1 text-xs text-yellow-300 font-mono
[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="submit"
className="text-xs px-2 py-1 rounded border border-gray-600 bg-gray-800 text-gray-300 hover:text-white"
>
이동
</button>
</form>
{videoId && (
<HlsConversionStatus
videoId={videoId}
onConversionDone={() => switchToHls(videoId)}
/>
)}
<span className="text-gray-500 text-xs ml-auto hidden sm:inline">
Space 재생 | / 5 | J/L 10 | ,/. 프레임 | Shift+S 캡처
</span>
</div>
)}
</div>
)}
</div>
);
}
);
export default VideoPlayer;