기록

미디어 로드 상태와 이벤트 대기

TL;DR

  • 미디어 이벤트는 상태가 아니라 상태 전이를 알린다.
  • 이미 loadedmetadata가 지난 <video>에 다시 이벤트를 기다리면 Promise가 끝나지 않을 수 있다.
  • 준비 상태는 readyState를 먼저 읽고, 아직 부족할 때만 이벤트를 구독해야 한다.
  • React state는 video.paused, duration, currentTime 같은 DOM 상태의 사본이다. 진실은 미디어 엘리먼트에 있다.

두 개의 <video>를 나란히 두고, 목록에서 항목을 선택하면 각 영상이 서로 다른 시작 시각으로 이동한 뒤 동시에 재생되어야 했다. 요구사항은 단순해 보인다.

left.currentTime = leftStart;
right.currentTime = rightStart;

await Promise.all([left.play(), right.play()]);

하지만 실제 구현에서는 세 가지를 먼저 확인해야 한다.

  1. <video>가 metadata를 알고 있는가.
  2. currentTime 대입이 실제 seek으로 이어졌는가.
  3. React의 재생 상태 사본이 DOM 엘리먼트 상태와 맞는가.

ref 분리

두 플레이어가 같은 ref를 공유하면 마지막에 마운트된 엘리먼트 하나만 남는다.

const videoRef = useRef<HTMLVideoElement>(null);

<CustomVideoPlayer ref={videoRef} videoUrl={leftUrl} />
<CustomVideoPlayer ref={videoRef} videoUrl={rightUrl} />

ref 객체는 current 하나만 가진다. 두 개의 video를 제어하려면 ref도 두 개여야 한다.

const leftVideoRef = useRef<HTMLVideoElement>(null);
const rightVideoRef = useRef<HTMLVideoElement>(null);

이 문제는 미디어 로딩보다 앞선 기본 조건이다. 엘리먼트를 정확히 잡지 못하면 이후의 readyState, seek, play 제어도 모두 흔들린다.

이벤트는 전이 알림

loadedmetadata는 metadata를 사용할 수 있게 되는 순간 발생한다. 이미 그 상태에 도달한 엘리먼트에서 다시 기다린다고 이벤트가 다시 오지는 않는다. HTMLMediaElement에는 준비 상태를 나타내는 readyState가 있다.

이름 의미
0 HAVE_NOTHING 미디어 정보 없음
1 HAVE_METADATA 길이와 크기 등 metadata 사용 가능
2 HAVE_CURRENT_DATA 현재 위치의 데이터 사용 가능
3 HAVE_FUTURE_DATA 현재 이후 데이터 사용 가능
4 HAVE_ENOUGH_DATA 계속 재생할 수 있을 만큼 데이터 확보

metadata만 필요하다면 기준은 readyState >= 1이다. 잘못된 대기 코드는 이벤트만 기다린다.

function waitForMetadata(video: HTMLVideoElement) {
  return new Promise<void>((resolve) => {
    video.addEventListener("loadedmetadata", () => resolve(), { once: true });
  });
}

이 함수는 이미 metadata가 로드된 엘리먼트에서 끝나지 않는다. 읽기를 먼저 두고, 부족할 때만 구독해야 한다.

function waitForMetadata(video: HTMLVideoElement) {
  return new Promise<void>((resolve) => {
    if (video.readyState >= HTMLMediaElement.HAVE_METADATA) {
      resolve();
      return;
    }

video.addEventListener("loadedmetadata", () => resolve(), { once: true });
  });
}

상태는 속성으로 읽고, 변화는 이벤트로 듣는다.

duration의 짝

영상 길이를 알고 싶을 때 loadedmetadata만 보는 것도 부족할 수 있다.

<video
  src={videoUrl}
  onLoadedMetadata={(event) => {
    setDuration(event.currentTarget.duration);
  }}
/>

loadedmetadata는 metadata를 사용할 수 있다는 신호다. duration 값이 바뀌는 사건의 이름은 durationchange다.

useEffect(() => {
  const video = videoRef.current;
  if (!video) return;

const syncDuration = () => { setDuration(Number.isFinite(video.duration) ? video.duration : 0); }; syncDuration(); video.addEventListener("durationchange", syncDuration);

return () => {
    video.removeEventListener("durationchange", syncDuration);
  };
}, [videoUrl]);

읽기와 구독을 함께 둔다. 이미 값이 있으면 즉시 반영하고, 이후 값이 바뀌면 이벤트로 갱신한다.

seek과 play 순서

currentTime은 metadata가 없는 상태에서도 대입할 수 있다. 다만 이때 즉시 seek이 일어나는 것이 아니라 기본 재생 시작 위치로 저장될 수 있다. metadata가 준비되면 그 시각으로 이동한다. 두 video를 동시에 재생해야 하는 화면에서는 이 지연이 문제가 된다. 한쪽은 이미 준비되어 즉시 seek하고, 다른 쪽은 metadata를 기다렸다가 나중에 seek하면 출발 시점이 갈린다. 따라서 순서는 다음처럼 잡는다.

await Promise.all([
  waitForMetadata(left),
  waitForMetadata(right),
]);

left.currentTime = leftStart; right.currentTime = rightStart; await Promise.all([ waitForSeeked(left), waitForSeeked(right), ]);

await Promise.all([
  left.play(),
  right.play(),
]);

seek 완료도 시간으로 때우지 않는 편이 낫다.

function waitForSeeked(video: HTMLVideoElement) {
  return new Promise<void>((resolve) => {
    if (!video.seeking) {
      resolve();
      return;
    }

video.addEventListener("seeked", () => resolve(), { once: true });
  });
}

setTimeout(50)은 느린 환경에서는 부족하고 빠른 환경에서는 불필요하다. HTMLMediaElement에는 이미 seeked라는 사건 이름이 있다.

재생 상태의 소유권

커스텀 컨트롤을 만들면 isPlaying 같은 React state를 두기 쉽다.

const [isPlaying, setIsPlaying] = useState(false);

하지만 재생 중인지의 실제 상태는 React state가 아니라 <video> 엘리먼트가 가진다.

video.paused;
video.ended;

React state는 화면에 그리기 위한 사본이다. 사본을 여러 곳에서 직접 쓰면 엘리먼트 상태와 어긋난다.

useEffect(() => {
  const video = videoRef.current;
  if (!video) return;

const syncPlaying = () => { setIsPlaying(!video.paused); }; syncPlaying(); video.addEventListener("play", syncPlaying); video.addEventListener("pause", syncPlaying); video.addEventListener("ended", syncPlaying);

return () => {
    video.removeEventListener("play", syncPlaying);
    video.removeEventListener("pause", syncPlaying);
    video.removeEventListener("ended", syncPlaying);
  };
}, []);

누가 video.play()를 호출하든, 상태 사본은 video event를 통해 갱신된다.

async function togglePlay() {
  const video = videoRef.current;
  if (!video) return;

if (video.paused) {
    await video.play();
  } else {
    video.pause();
  }
}

버튼 클릭 핸들러가 직접 setIsPlaying(true)를 호출하지 않아도 된다. 상태 변경은 엘리먼트 이벤트에서만 일어난다.

속성과 이벤트의 짝

HTMLMediaElement를 다룰 때는 알고 싶은 값마다 읽을 속성과 구독할 이벤트를 함께 둔다.

알고 싶은 것 읽을 속성 구독할 이벤트
metadata 준비 readyState >= 1 loadedmetadata
길이 duration durationchange
재생 상태 paused, ended play, pause, ended
현재 시각 currentTime timeupdate, seeking, seeked
seek 완료 seeking === false seeked

속성만 읽으면 이후 변화를 놓친다. 이벤트만 기다리면 이미 지나간 전이를 놓친다. 둘을 같이 써야 한다.

정리

미디어 엘리먼트에서 이벤트는 현재 상태가 아니라 상태 전이를 알려준다. 이미 loadedmetadata가 끝난 video에 다시 이벤트를 기다리면 아무 일도 일어나지 않는다. 안정적인 제어 순서는 이렇다.

  1. ref를 엘리먼트 수만큼 분리한다.
  2. readyState를 먼저 읽고 부족할 때만 이벤트를 기다린다.
  3. seek은 currentTime 대입 후 seeked로 확인한다.
  4. React state는 DOM 상태의 사본으로 두고, 쓰기는 미디어 이벤트 한 곳에 모은다.

타이머를 넣기 전에 브라우저가 이미 이름 붙여 둔 이벤트가 있는지 먼저 찾는다. 미디어 API에는 대부분 그 이름이 있다.

관련 글