- 클라이언트(React/Vite): Video.js 플레이어, 하단 스테이션바, POI/구조물 영상 오버레이, 카메라 파라미터 보정, 미니맵/RoutePanel - 서버(Express): Range 스트리밍, HLS 변환, 프레임 추출, tus 업로드, 주석 API - 최근 작업: 스테이션바 종점역 표출/양끝 정렬/미도착(재생불가) 표시, POI 라벨 '구분' 우선·컴팩트 팝업·겹침제외 토글·동일좌표 다중행, 진행방향(상/하) 우선 표출, 커서 배지 크기·픽셀 떨림 개선 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
114 lines
3.3 KiB
TypeScript
114 lines
3.3 KiB
TypeScript
import { useEffect, useCallback } from 'react';
|
|
import type Player from 'video.js/dist/types/player';
|
|
|
|
interface KeyboardOptions {
|
|
playerRef: React.RefObject<Player | null>;
|
|
onStepForward: () => void;
|
|
onStepBackward: () => void;
|
|
onCaptureFrame: () => void;
|
|
onAddMemo: () => void;
|
|
onToggleHelp?: () => void;
|
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
}
|
|
|
|
export function useKeyboard({
|
|
playerRef,
|
|
onStepForward,
|
|
onStepBackward,
|
|
onCaptureFrame,
|
|
onAddMemo,
|
|
onToggleHelp,
|
|
containerRef,
|
|
}: KeyboardOptions) {
|
|
const handleKey = useCallback(
|
|
(e: KeyboardEvent) => {
|
|
const target = e.target as HTMLElement;
|
|
// Don't fire when typing in an input
|
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
|
|
|
|
const player = playerRef.current;
|
|
if (!player) return;
|
|
|
|
const ct = player.currentTime() ?? 0;
|
|
const dur = player.duration() ?? 0;
|
|
|
|
switch (e.code) {
|
|
case 'Space':
|
|
e.preventDefault();
|
|
player.paused() ? player.play() : player.pause();
|
|
break;
|
|
case 'ArrowLeft':
|
|
e.preventDefault();
|
|
player.currentTime(Math.max(0, ct - 5));
|
|
break;
|
|
case 'ArrowRight':
|
|
e.preventDefault();
|
|
player.currentTime(Math.min(dur, ct + 5));
|
|
break;
|
|
case 'KeyJ':
|
|
player.currentTime(Math.max(0, ct - 10));
|
|
break;
|
|
case 'KeyL':
|
|
player.currentTime(Math.min(dur, ct + 10));
|
|
break;
|
|
case 'Comma':
|
|
e.preventDefault();
|
|
onStepBackward();
|
|
break;
|
|
case 'Period':
|
|
e.preventDefault();
|
|
onStepForward();
|
|
break;
|
|
case 'BracketLeft':
|
|
player.currentTime(Math.max(0, ct - 30));
|
|
break;
|
|
case 'BracketRight':
|
|
player.currentTime(Math.min(dur, ct + 30));
|
|
break;
|
|
case 'KeyF':
|
|
if (containerRef.current) {
|
|
if (!document.fullscreenElement) {
|
|
containerRef.current.requestFullscreen();
|
|
} else {
|
|
document.exitFullscreen();
|
|
}
|
|
}
|
|
break;
|
|
case 'KeyM':
|
|
if (e.shiftKey) {
|
|
e.preventDefault();
|
|
onAddMemo();
|
|
} else {
|
|
player.muted(!player.muted());
|
|
}
|
|
break;
|
|
case 'Equal':
|
|
case 'NumpadAdd':
|
|
player.playbackRate(Math.min(4, (player.playbackRate() ?? 1) + 0.25));
|
|
break;
|
|
case 'Minus':
|
|
case 'NumpadSubtract':
|
|
player.playbackRate(Math.max(0.25, (player.playbackRate() ?? 1) - 0.25));
|
|
break;
|
|
case 'KeyS':
|
|
if (e.shiftKey) { e.preventDefault(); onCaptureFrame(); }
|
|
break;
|
|
case 'Slash':
|
|
if (e.shiftKey) { e.preventDefault(); onToggleHelp?.(); }
|
|
break;
|
|
default:
|
|
if (e.code >= 'Digit0' && e.code <= 'Digit9') {
|
|
const pct = parseInt(e.code.replace('Digit', ''), 10) / 10;
|
|
player.currentTime(dur * pct);
|
|
}
|
|
}
|
|
},
|
|
[playerRef, onStepForward, onStepBackward, onCaptureFrame, onAddMemo, onToggleHelp, containerRef]
|
|
);
|
|
|
|
useEffect(() => {
|
|
window.addEventListener('keydown', handleKey);
|
|
return () => window.removeEventListener('keydown', handleKey);
|
|
}, [handleKey]);
|
|
}
|