재생 중 시크(재생바·방향키) 개선 — 요소 duration 폴백 + 스트림 재시작 디바운스

증상: 재생 중 재생바 이동이나 방향키 이동이 잘 안 됨.
원인 두 가지:
1) 재생바가 오직 ffprobe 메타(pb.durationMs)에만 의존 → 메타 duration이
   없으면(중단된 스캔·일부 포맷) durationSec=0이라 seekFromPointer가 항상 0으로
   이동. 직접 재생인데도 <video> 요소가 아는 실제 길이를 안 썼다.
2) 스트림(트랜스코딩/리먹스) 모드는 시크마다 ffmpeg 재시작 → 재생바 드래그 시
   pointermove마다 재시작해 스래싱.

수정:
- durationSec(): 직접 모드는 요소 duration 우선(메타 없어도 시크바 동작),
  스트림 모드는 -ss로 잘린 요소값이 부정확하므로 메타 우선. 요소 duration은
  유한·양수일 때만 채택.
- 스트림 시크는 250ms 디바운스로 마지막 위치 한 번만 재시작(드래그·연타 합침),
  seekingTo로 재시작 전 표시 위치 고정(옛 스트림이 되돌리지 않게).
- 길이 미상이면 재생바 클릭 무시.

검증(직접 재생 VP9): 방향키 2→7→2, 재생바 50%→6.0s / 25%→3.0s / 80%→9.6s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
강 한
2026-07-19 20:36:16 +09:00
co-authored by Claude Fable 5
parent e95772e79b
commit ce94842a8a
+44 -10
View File
@@ -52,11 +52,23 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
const [rate, setRate] = createSignal(1); const [rate, setRate] = createSignal(1);
const [fullscreen, setFullscreen] = createSignal(false); const [fullscreen, setFullscreen] = createSignal(false);
const [controlsShown, setControlsShown] = createSignal(true); const [controlsShown, setControlsShown] = createSignal(true);
// <video> 요소가 보고한 길이 (직접 재생 시 메타데이터보다 정확·항상 존재)
const [elDuration, setElDuration] = createSignal(0);
// 스트림 시크 중 표시 위치 고정용 (null이면 실제 재생 위치 사용)
const [seekingTo, setSeekingTo] = createSignal<number | null>(null);
let jobId: number | undefined; let jobId: number | undefined;
let hideTimer: ReturnType<typeof setTimeout> | undefined; let hideTimer: ReturnType<typeof setTimeout> | undefined;
let streamSeekTimer: ReturnType<typeof setTimeout> | undefined;
const durationSec = () => (pb()?.durationMs ?? 0) / 1000;
const isStream = () => plan()?.mode === "stream"; const isStream = () => plan()?.mode === "stream";
// 재생 길이: 직접 모드는 요소 duration이 진실(메타 없어도 시크바 동작),
// 스트림 모드는 -ss로 잘린 요소 duration이 부정확하므로 메타데이터 우선.
const durationSec = () => {
const meta = (pb()?.durationMs ?? 0) / 1000;
const ed = elDuration();
if (isStream()) return meta > 0 ? meta : ed;
return ed > 0 ? ed : meta;
};
const cleanupJob = () => { const cleanupJob = () => {
if (jobId != null) { if (jobId != null) {
@@ -80,9 +92,12 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
const app = appInfoSig(); const app = appInfoSig();
if (!p || !app) return; if (!p || !app) return;
cleanupJob(); cleanupJob();
clearTimeout(streamSeekTimer);
setSeekBase(0); setSeekBase(0);
setCurrent(0); setCurrent(0);
setBuffered(0); setBuffered(0);
setSeekingTo(null);
setElDuration(0);
const decided = decidePlayback(p); const decided = decidePlayback(p);
setPlan(decided); setPlan(decided);
if (decided.mode === "direct") { if (decided.mode === "direct") {
@@ -106,6 +121,7 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
onCleanup(() => { onCleanup(() => {
cleanupJob(); cleanupJob();
clearTimeout(hideTimer); clearTimeout(hideTimer);
clearTimeout(streamSeekTimer);
}); });
// ── 재생 제어 ── // ── 재생 제어 ──
@@ -119,12 +135,15 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
const p = plan(); const p = plan();
if (!p) return; if (!p) return;
const clamped = Math.max(0, Math.min(durationSec() || t, t)); const clamped = Math.max(0, Math.min(durationSec() || t, t));
setCurrent(clamped);
if (p.mode === "direct") { if (p.mode === "direct") {
setSeekingTo(null);
if (videoRef) videoRef.currentTime = clamped; if (videoRef) videoRef.currentTime = clamped;
setCurrent(clamped);
} else if (p.mode === "stream") { } else if (p.mode === "stream") {
setCurrent(clamped); // ffmpeg 재시작은 비싸다 — 연속 시크(드래그·연타)는 마지막 위치로 합쳐 한 번만 재시작.
void startStreamAt(clamped, p.video, p.audio); setSeekingTo(clamped);
clearTimeout(streamSeekTimer);
streamSeekTimer = setTimeout(() => void startStreamAt(clamped, p.video, p.audio), 250);
} }
}; };
@@ -162,17 +181,19 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
}; };
// ── 시크바 상호작용 ── // ── 시크바 상호작용 ──
const seekFromPointer = (clientX: number) => { const posToTime = (clientX: number): number => {
if (!barRef) return; if (!barRef) return 0;
const rect = barRef.getBoundingClientRect(); const rect = barRef.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
seekTo(ratio * (durationSec() || 0)); return ratio * (durationSec() || 0);
}; };
const onBarPointerDown = (e: PointerEvent) => { const onBarPointerDown = (e: PointerEvent) => {
if (durationSec() <= 0) return; // 길이 미상이면 시크 불가
e.preventDefault(); e.preventDefault();
seekFromPointer(e.clientX); // 드래그 중엔 표시 위치만 즉시 갱신(seekTo가 setCurrent). 스트림은 손 뗄 때 한 번만 재시작.
const move = (me: PointerEvent) => seekFromPointer(me.clientX); seekTo(posToTime(e.clientX));
const move = (me: PointerEvent) => seekTo(posToTime(me.clientX));
const up = () => { const up = () => {
window.removeEventListener("pointermove", move); window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up); window.removeEventListener("pointerup", up);
@@ -263,13 +284,21 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
}); });
const onTimeUpdate = () => { const onTimeUpdate = () => {
// 스트림 시크가 예정된 동안(재시작 전)엔 표시 위치를 고정 — 옛 스트림이 되돌리지 않게.
if (seekingTo() == null) {
setCurrent(seekBase() + (videoRef?.currentTime ?? 0)); setCurrent(seekBase() + (videoRef?.currentTime ?? 0));
}
// 버퍼 끝(직접 모드) // 버퍼 끝(직접 모드)
if (videoRef && videoRef.buffered.length > 0 && !isStream()) { if (videoRef && videoRef.buffered.length > 0 && !isStream()) {
setBuffered(videoRef.buffered.end(videoRef.buffered.length - 1)); setBuffered(videoRef.buffered.end(videoRef.buffered.length - 1));
} }
}; };
const onLoadedMeta = () => {
const d = videoRef?.duration ?? 0;
setElDuration(Number.isFinite(d) && d > 0 ? d : 0);
};
const rateLabel = () => (rate() === 1 ? "1x" : `${rate()}x`); const rateLabel = () => (rate() === 1 ? "1x" : `${rate()}x`);
return ( return (
@@ -296,6 +325,8 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
playsinline playsinline
onTimeUpdate={onTimeUpdate} onTimeUpdate={onTimeUpdate}
onProgress={onTimeUpdate} onProgress={onTimeUpdate}
onLoadedMetadata={onLoadedMeta}
onDurationChange={onLoadedMeta}
onPlay={() => { onPlay={() => {
setPlaying(true); setPlaying(true);
showControls(); showControls();
@@ -306,7 +337,10 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
clearTimeout(hideTimer); clearTimeout(hideTimer);
}} }}
onWaiting={() => setWaiting(true)} onWaiting={() => setWaiting(true)}
onPlaying={() => setWaiting(false)} onPlaying={() => {
setWaiting(false);
setSeekingTo(null); // 시크 후 새 스트림 재생 시작 → 표시 위치 고정 해제
}}
onCanPlay={() => setWaiting(false)} onCanPlay={() => setWaiting(false)}
onEnded={onEnded} onEnded={onEnded}
onClick={togglePlay} onClick={togglePlay}