모니터 측에서 영상 재생이 실제 종료 시점보다 약 1초 일찍 종료 처리되는 현상. 게스트 측에서 onEnd 이벤트가 발화되면 소켓을 통해 호스트/모니터로 종료 알림이 전달되는데, 이 알림이 너무 이르게 발생했음.
timeupdate 핸들러에서 currentTime >= duration - 0.75 조건으로 onEnd를 조기 발화함. 영상이 실제로 끝나기 0.75초 전에 종료 처리가 시작되었음.
게스트 onEnd 소켓 이벤트가 호스트로 약 74ms 내에 도달하여 호스트의 videoSrc를 null로 설정. 그러나 호스트 측 video 엘리먼트는 계속 재생 중이고, timeupdate 이벤트가 발화될 때 stale state로 onEnd가 이중 발화됨.
iOS Safari 등 일부 환경에서 ended 이벤트가 발화되지 않을 수 있으나, 기존 durationTimeout 로직은 ended 이벤트 발화 여부와 무관하게 항상 onEnd를 발화시키는 구조였음.
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| near-end 임계값 | duration - 0.75 |
duration - 0.1 |
| ended 이벤트 추적 | 없음 | endedEventFiredRef 추가 |
| videoSrc 클로저 안전성 | props.videoSrc 직접 참조 |
videoSrcRef.current 경유 |
| durationTimeout 조건 | 항상 onEnd 발화 | ended 미발화시에만 발화 + 재시도 로직(최대 5회) |
| near-end → durationTimeout | 미해제 | near-end 발화 시 durationTimeout clearTimeout |
| handleEnded null 가드 순서 | null 체크 → clearDurationTimeout | clearDurationTimeout → null 체크 |
ended 이벤트 발화 여부를 추적하는 ref. durationTimeout이 ended 미발화 시에만 onEnd를 호출하도록 제어하는 핵심 플래그.
const endedEventFiredRef = useRef(false); // videoUrl 변경 시, loadstart 시 리셋 endedEventFiredRef.current = false;
setTimeout 클로저에서 props.videoSrc를 직접 참조하면 closure 생성 시점의 값이 고정되어 stale closure 문제가 발생함. ref를 통해 항상 최신 값을 참조.
const videoSrcRef = useRef(props.videoSrc);
useEffect(() => {
videoSrcRef.current = props.videoSrc;
}, [props.videoSrc]);
영상 길이만큼 대기 후 ended 이벤트가 발화되지 않았을 경우에만 폴백으로 onEnd를 호출. 아직 재생이 완료되지 않았다면(remaining > 0.3) 최대 5회까지 재스케줄.
const scheduleCheck = (delayMs: number, attempt = 0) => { durationTimeoutRef.current = setTimeout(() => { durationTimeoutRef.current = null; // ended 이벤트 발화됨 / 이미 onEnd 호출됨 / 재생 중 아님 / videoSrc 없음 → skip if (endedEventFiredRef.current || onEndCalledRef.current || !isPlayingRef.current || !videoSrcRef.current) return; const remaining = video.duration - video.currentTime; if (remaining > 0.3 && attempt < 5) { scheduleCheck(remaining * 1000, attempt + 1); return; } logger.warn("Video duration timeout: ended event not fired, triggering onEnd fallback", { videoSrc: props.videoSrc, duration: video.duration, currentTime: video.currentTime, }); onEndCalledRef.current = true; props.onEnd?.(); }, delayMs); }; scheduleCheck(remainingSec * 1000);
기존에는 videoSrc null 체크가 clearDurationTimeout보다 먼저 실행되어, videoSrc가 null이면 타임아웃이 해제되지 않는 버그가 있었음. 순서를 변경하여 항상 타임아웃을 먼저 해제.
const handleEnded = () => { endedEventFiredRef.current = true; // ...로깅... clearDurationTimeout(); // null 체크 전에 반드시 실행 if (!videoSrcRef.current) return; if (!onEndCalledRef.current) { onEndCalledRef.current = true; props.onEnd?.(); } };
near-end 폴백의 역할은 ended 이벤트가 실제 종료 시점보다 늦게 오거나 안 오는 경우의 안전망. 0.75초는 너무 이르기 때문에 0.1초로 줄임. near-end 발화 시 durationTimeout도 함께 해제.
if ( videoSrcRef.current && // videoSrc null 가드 (race condition 방어) !nearEndLoggedRef.current && Number.isFinite(video.duration) && video.duration > 0 && video.currentTime >= Math.max(0, video.duration - 0.1) // 0.75 → 0.1 ) { nearEndLoggedRef.current = true; if (!onEndCalledRef.current) { onEndCalledRef.current = true; clearDurationTimeout(); // durationTimeout 해제 props.onEnd?.(); } }
HTML5 video 엘리먼트가 재생 완료 시 자동 발화. endedEventFiredRef를 true로 설정하여 다른 경로가 중복 발화하지 않도록 차단.
timeupdate 이벤트에서 재생 위치가 종료 0.1초 전에 도달하면 발화. ended가 늦게 오는 경우 대비. 발화 시 durationTimeout 해제.
iOS Safari 등 ended 이벤트가 아예 발화되지 않는 환경 대비. endedEventFiredRef가 false인 경우에만 onEnd 호출. 재생이 아직 완료되지 않았다면 최대 5회 재스케줄.
각 경로는 onEndCalledRef를 통해 중복 발화를 방지하며, 먼저 발화한 경로가 다른 경로를 차단함.
currentTime >= duration - 0.75 조건 충족 → onEnd 조기 발화 (약 0.75초 이른 시점)videoSrc = null 설정 (~74ms)durationTimeout도 onEnd 발화 → 이중 발화 가능성ended 이벤트 발화 → onEnd 발화ended 미발화 시: currentTime >= duration - 0.1 조건에서 near-end 폴백 발화ended도 near-end도 발화 안 될 경우: durationTimeout이 최후 폴백으로 발화videoSrc = null 후 videoSrcRef 가드로 중복 onEnd 차단