Files
GhiVideo_v4/client/src/stationbar/components/PlaybackControls/PlaybackControls.tsx
T
b23042andClaude Fable 5 d38b842e8d GhiVideo 소스 복제 — v4 작업 시작 기준
기존 GhiVideo 저장소 HEAD의 트래킹 소스 362개 파일을 복제.
(node_modules·storage·빌드 산출물·대용량 미디어는 .gitignore 규칙대로 제외)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:32:51 +09:00

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>
);
}