재생 중 시크(재생바·방향키) 개선 — 요소 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:
@@ -52,11 +52,23 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
const [rate, setRate] = createSignal(1);
|
||||
const [fullscreen, setFullscreen] = createSignal(false);
|
||||
const [controlsShown, setControlsShown] = createSignal(true);
|
||||
// <video> 요소가 보고한 길이 (직접 재생 시 메타데이터보다 정확·항상 존재)
|
||||
const [elDuration, setElDuration] = createSignal(0);
|
||||
// 스트림 시크 중 표시 위치 고정용 (null이면 실제 재생 위치 사용)
|
||||
const [seekingTo, setSeekingTo] = createSignal<number | null>(null);
|
||||
let jobId: number | undefined;
|
||||
let hideTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let streamSeekTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const durationSec = () => (pb()?.durationMs ?? 0) / 1000;
|
||||
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 = () => {
|
||||
if (jobId != null) {
|
||||
@@ -80,9 +92,12 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
const app = appInfoSig();
|
||||
if (!p || !app) return;
|
||||
cleanupJob();
|
||||
clearTimeout(streamSeekTimer);
|
||||
setSeekBase(0);
|
||||
setCurrent(0);
|
||||
setBuffered(0);
|
||||
setSeekingTo(null);
|
||||
setElDuration(0);
|
||||
const decided = decidePlayback(p);
|
||||
setPlan(decided);
|
||||
if (decided.mode === "direct") {
|
||||
@@ -106,6 +121,7 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
onCleanup(() => {
|
||||
cleanupJob();
|
||||
clearTimeout(hideTimer);
|
||||
clearTimeout(streamSeekTimer);
|
||||
});
|
||||
|
||||
// ── 재생 제어 ──
|
||||
@@ -119,12 +135,15 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
const p = plan();
|
||||
if (!p) return;
|
||||
const clamped = Math.max(0, Math.min(durationSec() || t, t));
|
||||
setCurrent(clamped);
|
||||
if (p.mode === "direct") {
|
||||
setSeekingTo(null);
|
||||
if (videoRef) videoRef.currentTime = clamped;
|
||||
setCurrent(clamped);
|
||||
} else if (p.mode === "stream") {
|
||||
setCurrent(clamped);
|
||||
void startStreamAt(clamped, p.video, p.audio);
|
||||
// ffmpeg 재시작은 비싸다 — 연속 시크(드래그·연타)는 마지막 위치로 합쳐 한 번만 재시작.
|
||||
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) => {
|
||||
if (!barRef) return;
|
||||
const posToTime = (clientX: number): number => {
|
||||
if (!barRef) return 0;
|
||||
const rect = barRef.getBoundingClientRect();
|
||||
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) => {
|
||||
if (durationSec() <= 0) return; // 길이 미상이면 시크 불가
|
||||
e.preventDefault();
|
||||
seekFromPointer(e.clientX);
|
||||
const move = (me: PointerEvent) => seekFromPointer(me.clientX);
|
||||
// 드래그 중엔 표시 위치만 즉시 갱신(seekTo가 setCurrent). 스트림은 손 뗄 때 한 번만 재시작.
|
||||
seekTo(posToTime(e.clientX));
|
||||
const move = (me: PointerEvent) => seekTo(posToTime(me.clientX));
|
||||
const up = () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
@@ -263,13 +284,21 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
});
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
setCurrent(seekBase() + (videoRef?.currentTime ?? 0));
|
||||
// 스트림 시크가 예정된 동안(재시작 전)엔 표시 위치를 고정 — 옛 스트림이 되돌리지 않게.
|
||||
if (seekingTo() == null) {
|
||||
setCurrent(seekBase() + (videoRef?.currentTime ?? 0));
|
||||
}
|
||||
// 버퍼 끝(직접 모드)
|
||||
if (videoRef && videoRef.buffered.length > 0 && !isStream()) {
|
||||
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`);
|
||||
|
||||
return (
|
||||
@@ -296,6 +325,8 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
playsinline
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onProgress={onTimeUpdate}
|
||||
onLoadedMetadata={onLoadedMeta}
|
||||
onDurationChange={onLoadedMeta}
|
||||
onPlay={() => {
|
||||
setPlaying(true);
|
||||
showControls();
|
||||
@@ -306,7 +337,10 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
clearTimeout(hideTimer);
|
||||
}}
|
||||
onWaiting={() => setWaiting(true)}
|
||||
onPlaying={() => setWaiting(false)}
|
||||
onPlaying={() => {
|
||||
setWaiting(false);
|
||||
setSeekingTo(null); // 시크 후 새 스트림 재생 시작 → 표시 위치 고정 해제
|
||||
}}
|
||||
onCanPlay={() => setWaiting(false)}
|
||||
onEnded={onEnded}
|
||||
onClick={togglePlay}
|
||||
|
||||
Reference in New Issue
Block a user