기존 GhiVideo 저장소 HEAD의 트래킹 소스 362개 파일을 복제. (node_modules·storage·빌드 산출물·대용량 미디어는 .gitignore 규칙대로 제외) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
116 lines
4.0 KiB
TypeScript
Executable File
116 lines
4.0 KiB
TypeScript
Executable File
import { useEffect, useState } from 'react';
|
|
import type { ChangeEvent, KeyboardEvent } from 'react';
|
|
import { parseMileageQuery } from '../../utils/mileage';
|
|
import styles from './PlaybackControls.module.scss';
|
|
|
|
interface PlaybackControlsProps {
|
|
playing: boolean;
|
|
onTogglePlay: () => void;
|
|
onStop: () => void;
|
|
onCapture: () => void;
|
|
/** 측점 검색: 이동했으면 true, 이 영상에 없는 측점이라 이동 못 했으면 false. */
|
|
onJumpToMileage: (mileage: number) => boolean;
|
|
/** 측점선 토글을 외부 상태로 제어할 때 사용(미지정 시 내부 상태). */
|
|
lineOn?: boolean;
|
|
onToggleLine?: () => void;
|
|
}
|
|
|
|
export function PlaybackControls({
|
|
playing,
|
|
onTogglePlay,
|
|
onStop,
|
|
onCapture,
|
|
onJumpToMileage,
|
|
lineOn: lineOnProp,
|
|
onToggleLine,
|
|
}: PlaybackControlsProps) {
|
|
const [query, setQuery] = useState('');
|
|
const [notFound, setNotFound] = useState(false);
|
|
const [lineOnInternal, setLineOnInternal] = useState(false);
|
|
const lineOn = lineOnProp ?? lineOnInternal;
|
|
const toggleLine = onToggleLine ?? (() => setLineOnInternal((v) => !v));
|
|
|
|
// '측점 없음' 안내는 잠깐만 표시(1.8초 후 자동 사라짐).
|
|
useEffect(() => {
|
|
if (!notFound) return;
|
|
const t = setTimeout(() => setNotFound(false), 1800);
|
|
return () => clearTimeout(t);
|
|
}, [notFound]);
|
|
|
|
const handleQueryChange = (e: ChangeEvent<HTMLInputElement>): void => {
|
|
setQuery(e.target.value);
|
|
setNotFound(false); // 다시 입력하면 안내 숨김
|
|
};
|
|
|
|
const handleQueryKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
|
if (e.key !== 'Enter') return;
|
|
const mileage = parseMileageQuery(query);
|
|
// Enter 후에도 입력값 유지 → 같은 측점 재Enter 시 통과방향 다음 위치로 순환 검색.
|
|
// 이 영상에 없는 측점(이동 실패) 또는 형식 오류면 '측점 없음' 안내를 잠깐 띄운다.
|
|
const moved = mileage !== null ? onJumpToMileage(mileage) : false;
|
|
setNotFound(!moved);
|
|
};
|
|
|
|
// 포커스를 잃으면(다른 곳 클릭 등) 입력값 삭제.
|
|
const handleQueryBlur = (): void => { setQuery(''); setNotFound(false); };
|
|
|
|
return (
|
|
<div className={styles.controlsRow}>
|
|
<div className={styles.transportGroup}>
|
|
<div className={styles.leftPanel} />
|
|
<button
|
|
type="button"
|
|
className={styles.transportBtn}
|
|
onClick={onTogglePlay}
|
|
aria-label={playing ? '일시정지' : '재생'}
|
|
>
|
|
{/* 호버 시 _on SVG 로 교체(:hover). 재생/일시정지는 상태로 아이콘 전환 */}
|
|
<span className={playing ? styles.pauseIcon : styles.playIcon} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={styles.stopBtn}
|
|
onClick={onStop}
|
|
aria-label="정지"
|
|
>
|
|
<span className={styles.stopIcon} />
|
|
</button>
|
|
<input
|
|
className={styles.mileageInput}
|
|
value={query}
|
|
onChange={handleQueryChange}
|
|
onKeyDown={handleQueryKeyDown}
|
|
onBlur={handleQueryBlur}
|
|
placeholder="측점입력"
|
|
/>
|
|
{notFound && (
|
|
<span role="alert" className={styles.notFound}>⚠ 측점 없음</span>
|
|
)}
|
|
</div>
|
|
<div className={styles.toolGroup}>
|
|
<button
|
|
type="button"
|
|
className={`${styles.toolBtn} ${styles.captureBtn}`}
|
|
onClick={onCapture}
|
|
aria-label="화면캡처"
|
|
>
|
|
<span className={styles.cameraIcon} />
|
|
<span className={styles.toolLabel}>화면캡처</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${styles.toolBtn} ${styles.lineBtn}${lineOn ? ` ${styles.active}` : ''}`}
|
|
onClick={toggleLine}
|
|
aria-pressed={lineOn}
|
|
aria-label="측점선 보기"
|
|
>
|
|
<span className={styles.lineIcon} />
|
|
<span className={styles.toolLabel}>
|
|
{lineOn ? '측점선 끄기' : '측점선 보기'}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|