junyeokk
Blog
Media·2025. 09. 13

Video Player Custom Controls

브라우저 기본 비디오 컨트롤(<video controls>)은 빠르게 프로토타입을 만들 때는 편하지만, 실제 프로덕션에서는 한계가 뚜렷하다. 디자인 커스터마이징이 거의 불가능하고, 플랫폼마다 UI가 다르게 렌더링되며, 비디오 외부 요소(타임스탬프 클릭, 단축키, 배속 컨트롤 등)와의 연동이 어렵다. YouTube IFrame API 같은 외부 플레이어를 사용하면 네이티브 <video> 태그와 인터페이스 자체가 달라서, 통합 제어가 더 복잡해진다.

이 문제를 해결하려면 플레이어의 내부 API를 추상화하는 커스텀 컨트롤 레이어를 만들어야 한다. 어떤 플레이어 엔진을 사용하든 동일한 인터페이스로 제어할 수 있게 하고, UI는 그 인터페이스에만 의존하게 만드는 패턴이다.


핵심 아이디어: PlayerControls 인터페이스

커스텀 컨트롤의 출발점은 플레이어가 할 수 있는 동작을 인터페이스로 정의하는 것이다. 네이티브 HTML5 비디오든, YouTube IFrame이든, 어떤 플레이어든 이 인터페이스만 구현하면 나머지 UI 코드는 수정 없이 동작한다.

typescript
interface PlayerControls {
  play: () => Promise<void> | void;
  pause: () => void;
  seekTo: (time: number) => void;
  getPlayerState?: () => number;
  setPlaybackRate: (rate: number) => void;
  setVolume: (volume: number) => void;
  skipForward: (seconds?: number) => void;
  skipBackward: (seconds?: number) => void;
  toggleFullscreen: () => void;
  toggleMute: () => void;
}

play()Promise<void> | void인 이유는 HTML5 <video>play()는 Promise를 반환하지만, YouTube IFrame API의 playVideo()는 void를 반환하기 때문이다. 이런 차이를 인터페이스 레벨에서 흡수한다.

상태도 마찬가지로 통합 타입을 정의한다:

typescript
interface PlayerState {
  isPlaying: boolean;
  currentTime: number;
  duration: number;
  playbackRate: number;
  volume: number;
  isFullscreen: boolean;
  isMuted: boolean;
}

플레이어별 Controls 구현

인터페이스를 정의했으면, 각 플레이어 엔진에 맞게 구현체를 만든다.

YouTube IFrame API

YouTube IFrame API는 playVideo(), pauseVideo(), seekTo() 같은 고유 메서드를 사용한다. 이걸 PlayerControls 인터페이스에 맞게 래핑한다:

typescript
function createYouTubePlayerControls(params: {
  playerRef: { current: YTPlayer | null };
  containerRef?: { current: HTMLDivElement | null };
  cachedDurationRef?: { current: number | null };
  onStateChange?: (state: Partial<PlayerState>) => void;
}): PlayerControls {
  const { playerRef, containerRef, cachedDurationRef, onStateChange } = params;

  let lastSeekTarget: number | null = null;

  return {
    play: () => playerRef.current?.playVideo(),
    pause: () => playerRef.current?.pauseVideo(),
    seekTo: (time: number) => {
      if (!playerRef.current) return;

      // 동일 위치 중복 seeking 방지
      if (lastSeekTarget !== null && Math.abs(time - lastSeekTarget) < 0.1) {
        return;
      }

      lastSeekTarget = time;
      playerRef.current.seekTo(time, true);

      // 일정 시간 후 잠금 해제
      setTimeout(() => {
        lastSeekTarget = null;
      }, 500);

      if (onStateChange && cachedDurationRef) {
        onStateChange({
          currentTime: time,
          duration: cachedDurationRef.current || 0,
        });
      }
    },
    setPlaybackRate: (rate) => playerRef.current?.setPlaybackRate(rate),
    setVolume: (volume) => playerRef.current?.setVolume(volume * 100),
    // ...나머지 메서드
  };
}

여기서 주목할 점은 중복 seeking 방지 로직이다. seekTo가 빠르게 여러 번 호출되면 플레이어가 불안정해질 수 있다. lastSeekTarget으로 0.1초 이내의 중복 호출을 무시하고, 500ms 후에 잠금을 해제하는 방식으로 이 문제를 방어한다.

HTML5 Video

네이티브 <video> 요소는 속성과 메서드가 YouTube API와 다르다:

typescript
function createHTML5PlayerControls(params: {
  videoRef: { current: HTMLVideoElement | null };
  containerRef?: { current: HTMLDivElement | null };
  onStateChange?: (state: Partial<PlayerState>) => void;
}): PlayerControls {
  const { videoRef, containerRef, onStateChange } = params;

  return {
    play: () => videoRef.current?.play(),       // Promise 반환
    pause: () => videoRef.current?.pause(),
    seekTo: (time) => {
      if (videoRef.current) {
        videoRef.current.currentTime = time;    // 속성 할당 방식
      }
    },
    setPlaybackRate: (rate) => {
      if (videoRef.current) {
        videoRef.current.playbackRate = rate;   // YouTube는 메서드, HTML5는 속성
      }
    },
    setVolume: (volume) => {
      if (videoRef.current) {
        videoRef.current.volume = volume;       // 0~1 범위 (YouTube는 0~100)
      }
    },
    skipForward: (seconds = 10) => {
      if (videoRef.current) {
        videoRef.current.currentTime += seconds;
      }
    },
    skipBackward: (seconds = 10) => {
      if (videoRef.current) {
        videoRef.current.currentTime = Math.max(0, videoRef.current.currentTime - seconds);
      }
    },
    toggleFullscreen: async () => {
      if (document.fullscreenElement) {
        await document.exitFullscreen();
      } else {
        await containerRef?.current?.requestFullscreen();
      }
    },
    toggleMute: () => {
      if (videoRef.current) {
        videoRef.current.muted = !videoRef.current.muted;
      }
    },
  };
}

YouTube vs HTML5의 주요 차이점:

항목YouTube IFrame APIHTML5 <video>
재생playVideo() (void)play() (Promise)
탐색seekTo(time, allowSeekAhead)currentTime = time
볼륨setVolume(0~100)volume = 0~1
배속setPlaybackRate(rate)playbackRate = rate
음소거mute() / unMute()muted = true/false

인터페이스가 이 차이를 흡수하기 때문에, UI 코드에서는 controls.play()만 호출하면 된다.


상태 관리: 전역 Store 연동

플레이어 상태를 컴포넌트 로컬 state로 관리하면 문제가 생긴다. 재생 버튼, 프로그레스 바, 시간 표시, 배속 선택기 등 여러 컴포넌트가 동일한 상태를 읽고 써야 하기 때문이다. 이런 경우 Zustand 같은 전역 상태 관리 라이브러리로 Player Store를 만드는 게 효과적이다.

typescript
import { create } from 'zustand';

interface PlayerStoreState {
  isPlaying: boolean;
  progress: number;         // 0~100 퍼센트
  currentTime: string;      // "01:23" 형식
  totalTime: string;
  volume: number;            // 0~100
  speed: number;             // 0.25~2
  currentSeconds: number;
  totalSeconds: number;
  playerControls: PlayerControls | null;
}

interface PlayerStoreActions {
  play: () => void;
  pause: () => void;
  togglePlayPause: () => void;
  skipForward: () => void;
  skipBackward: () => void;
  setProgress: (progress: number) => void;
  setVolume: (volume: number) => void;
  setSpeed: (speed: number) => void;
  setDuration: (seconds: number) => void;
  updateCurrentTime: (seconds: number) => void;
  setPlayerControls: (controls: PlayerControls | null) => void;
  reset: () => void;
}

const usePlayerStore = create<PlayerStoreState & PlayerStoreActions>((set) => ({
  isPlaying: false,
  progress: 0,
  currentTime: '00:00',
  totalTime: '00:00',
  volume: 75,
  speed: 1,
  currentSeconds: 0,
  totalSeconds: 0,
  playerControls: null,

  play: () => set((state) => {
    state.playerControls?.play();
    return { isPlaying: true };
  }),
  pause: () => set((state) => {
    state.playerControls?.pause();
    return { isPlaying: false };
  }),
  setDuration: (seconds) => set({
    totalSeconds: seconds,
    totalTime: formatTime(seconds),
  }),
  updateCurrentTime: (seconds) => set((state) => ({
    currentSeconds: seconds,
    currentTime: formatTime(seconds),
    progress: state.totalSeconds > 0
      ? (seconds / state.totalSeconds) * 100
      : 0,
  })),
  reset: () => set({
    isPlaying: false,
    progress: 0,
    currentTime: '00:00',
    currentSeconds: 0,
  }),
  // ...기타 액션
}));

핵심은 store가 playerControls 참조를 보유한다는 점이다. play() 같은 store 액션이 호출되면, 내부적으로 playerControls.play()를 호출하면서 동시에 isPlaying 상태를 업데이트한다. UI는 store의 상태만 구독하면 되고, 실제 플레이어 API 호출은 store 내부에서 처리된다.


플레이어 ↔ Store 연결: usePlayerControls 훅

플레이어 컴포넌트가 마운트될 때 PlayerControls 인스턴스를 생성하고, 이를 store에 등록하는 과정이 필요하다. 이 연결 로직을 커스텀 훅으로 캡슐화한다:

typescript
function usePlayerControls() {
  const playerControlsRef = useRef<PlayerControls | null>(null);
  const isConnectedRef = useRef(false);

  const connectPlayerStore = useCallback(() => {
    if (isConnectedRef.current) return;
    isConnectedRef.current = true;

    // Store의 액션을 playerControlsRef와 연결
    usePlayerStore.setState({
      togglePlayPause: () => {
        const state = usePlayerStore.getState();
        const controls = playerControlsRef.current;
        if (!controls) return;

        if (state.isPlaying) {
          controls.pause();
        } else {
          controls.play();
        }
      },
      setProgress: (progress: number) => {
        const controls = playerControlsRef.current;
        if (!controls) return;

        const state = usePlayerStore.getState();
        const newSeconds = (progress / 100) * state.totalSeconds;
        controls.seekTo(newSeconds);
      },
      // ...기타 액션 오버라이드
    });
  }, []);

  const handlePlayerReady = useCallback((controls: PlayerControls) => {
    playerControlsRef.current = controls;
    if (!isConnectedRef.current) {
      connectPlayerStore();
    }
  }, [connectPlayerStore]);

  const handlePlayerStateChange = useCallback((state: Partial<PlayerState>) => {
    if (state.isPlaying !== undefined) {
      usePlayerStore.setState({ isPlaying: state.isPlaying });
    }
    if (state.currentTime !== undefined) {
      usePlayerStore.getState().updateCurrentTime(state.currentTime);
    }
    if (state.duration !== undefined) {
      usePlayerStore.getState().setDuration(state.duration);
    }
  }, []);

  return {
    playerControlsRef,
    handlePlayerReady,
    handlePlayerStateChange,
  };
}

흐름 정리:

  1. 페이지 마운트 → usePlayerControls() 호출
  2. 플레이어 컴포넌트 렌더링 → YouTube/HTML5 플레이어 초기화
  3. 플레이어 준비 완료 → handlePlayerReady(controls) 콜백
  4. 훅 내부에서 store 액션을 controls와 연결
  5. 이후 UI에서 store.togglePlayPause() 호출 → controls.pause() 실행

이 패턴의 장점은 플레이어가 교체되어도 UI가 변경되지 않는다는 것이다. YouTube에서 네이티브 비디오로 전환되면, handlePlayerReady에 새 controls가 전달되고, store 연결만 갱신된다.


키보드 단축키

비디오 플레이어에 키보드 단축키를 추가하면 사용성이 크게 향상된다. 이것도 별도 훅으로 분리하면 깔끔하다:

typescript
function useVideoKeyboardShortcuts(options: {
  enabled?: boolean;
  isVideoType?: boolean;
}) {
  const { enabled = true, isVideoType = true } = options;
  const { skipBackward, skipForward, togglePlayPause, speed, setSpeed } = usePlayerStore();

  useEffect(() => {
    if (!enabled) return;

    const handleKeyDown = (e: KeyboardEvent) => {
      // 입력 필드에 포커스가 있으면 무시
      const target = e.target as HTMLElement;
      if (
        target.tagName === 'INPUT' ||
        target.tagName === 'TEXTAREA' ||
        target.isContentEditable ||
        target.closest('[contenteditable="true"]')
      ) {
        return;
      }

      if (!isVideoType) return;

      switch (e.key) {
        case 'ArrowLeft':
          e.preventDefault();
          skipBackward();
          break;
        case 'ArrowRight':
          e.preventDefault();
          skipForward();
          break;
        case ' ':
          e.preventDefault();
          togglePlayPause();
          break;
        case '>':
        case '.':
          if (e.shiftKey) {
            e.preventDefault();
            setSpeed(Math.min(2, speed + 0.25));
          }
          break;
        case '<':
        case ',':
          if (e.shiftKey) {
            e.preventDefault();
            setSpeed(Math.max(0.25, speed - 0.25));
          }
          break;
      }
    };

    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [enabled, isVideoType, skipBackward, skipForward, togglePlayPause, speed, setSpeed]);
}

단축키 충돌 방지가 중요하다. input, textarea, contenteditable 요소에 포커스가 있을 때는 단축키를 무시해야 한다. 그렇지 않으면 텍스트 입력 중에 Space를 누를 때마다 비디오가 토글되는 문제가 발생한다.

배속 조절에서 Shift + > / Shift + <를 사용하는 건 YouTube의 단축키 관례를 따른 것이다. 0.25 단위로 증감하되, 0.25~2 범위를 벗어나지 않도록 Math.min / Math.max로 클램핑한다.


커스텀 PlayBar UI

컨트롤 UI는 store의 상태를 구독하고, store의 액션을 호출하는 순수한 프레젠테이션 컴포넌트로 만든다:

tsx
function PlayBarControls() {
  const isPlaying = usePlayerStore((s) => s.isPlaying);
  const { togglePlayPause, skipBackward, skipForward } = usePlayerStore();

  return (
    <div className="flex items-center">
      <button onClick={skipBackward} aria-label="10초 뒤로">
        <RewindIcon />
      </button>
      <button onClick={togglePlayPause} aria-label={isPlaying ? '일시정지' : '재생'}>
        {isPlaying ? <PauseIcon /> : <PlayIcon />}
      </button>
      <button onClick={skipForward} aria-label="10초 앞으로">
        <ForwardIcon />
      </button>
    </div>
  );
}

프로그레스 바는 슬라이더로 구현한다. 사용자가 드래그하면 setProgress를 호출하고, 플레이어의 시간이 변경되면 progress 상태가 자동 업데이트된다:

tsx
function PlayBarProgress() {
  const progress = usePlayerStore((s) => s.progress);
  const totalSeconds = usePlayerStore((s) => s.totalSeconds);
  const setProgress = usePlayerStore((s) => s.setProgress);

  const handleChange = (value: number[]) => {
    const newProgress = value[0];
    if (newProgress !== undefined) {
      setProgress(newProgress);
    }
  };

  return (
    <Slider
      value={[progress]}
      onValueChange={handleChange}
      max={100}
      min={0}
      step={0.1}
    />
  );
}

볼륨 컨트롤도 같은 패턴이다. Popover 안에 슬라이더를 넣고, 아이콘 클릭으로 음소거를 토글한다:

tsx
function VolumeControl() {
  const volume = usePlayerStore((s) => s.volume);
  const setVolume = usePlayerStore((s) => s.setVolume);

  return (
    <Popover>
      <PopoverTrigger>
        <VolumeIcon volume={volume} />
      </PopoverTrigger>
      <PopoverContent>
        <Slider
          value={[volume]}
          onValueChange={(v) => setVolume(v[0])}
          max={100}
          min={0}
        />
        <span>{volume}%</span>
      </PopoverContent>
    </Popover>
  );
}

풀스크린 처리

풀스크린은 Fullscreen API를 사용한다. 주의할 점은 <video> 요소가 아닌 컨테이너 <div>를 풀스크린으로 만들어야 커스텀 컨트롤이 함께 표시된다는 것이다:

typescript
toggleFullscreen: async () => {
  try {
    if (document.fullscreenElement) {
      await document.exitFullscreen();
    } else {
      // video가 아닌 container를 풀스크린으로
      await containerRef?.current?.requestFullscreen();
    }
  } catch (error) {
    console.error('Fullscreen error:', error);
  }
}

<video> 자체를 풀스크린으로 만들면 브라우저 기본 컨트롤이 표시되고, 커스텀 PlayBar는 보이지 않는다. 컨테이너를 풀스크린으로 만들면 컨테이너 내부의 모든 요소(비디오 + 오버레이 + 컨트롤)가 함께 풀스크린에 포함된다.


반응형: 데스크톱과 모바일 분리

같은 플레이어라도 데스크톱과 모바일에서 UI 요구사항이 크게 다르다. 데스크톱에서는 hover로 컨트롤을 표시하고, 모바일에서는 터치 이벤트와 제스처 기반 인터랙션이 필요하다. CSS 미디어 쿼리로 처리하기엔 차이가 너무 크다.

이런 경우 데스크톱/모바일 플레이어 컴포넌트를 아예 분리하는 게 유지보수하기 쉽다:

domain/media/components/viewer/ ├── DesktopYouTubePlayer.tsx ├── MobileYouTubePlayer.tsx ├── NativeVideoPlayer.tsx └── types.ts ← 공통 인터페이스

두 컴포넌트 모두 PlayerControls 인터페이스를 구현하므로, onPlayerReady 콜백에 전달하는 controls 객체의 구조는 동일하다. 차이는 UI 렌더링과 이벤트 핸들링뿐이다.

PlayBar도 variant 패턴으로 분리할 수 있다:

tsx
domain/media/components/viewer/
├── DesktopYouTubePlayer.tsx
├── MobileYouTubePlayer.tsx
├── NativeVideoPlayer.tsx
└── types.ts                  ← 공통 인터페이스

엣지 케이스 처리

play() 전 상태 문제

YouTube IFrame API에서 플레이어가 아직 비디오를 로드하지 않은 상태(state = -1 또는 5)에서 seekTo를 호출하면 무시된다. 이때는 먼저 play()를 호출한 뒤, 재생이 시작되면 seekTo를 실행해야 한다:

typescript
// variant에 따라 다른 레이아웃 렌더링
function PlayBar({ variant = 'basic' }: { variant: 'basic' | 'expanded' }) {
  return variant === 'expanded'
    ? <ExpandedPlayBar />   // 데스크톱용: 배속 선택기, 풀스크린 버튼 포함
    : <BasicPlayBar />;     // 모바일용: 최소한의 컨트롤
}

shouldPauseOnPlaying 플래그는 "play()로 재생을 시작했지만 실제로는 특정 시점으로 이동한 뒤 멈추고 싶은 상황"을 처리한다.

상태 변경 콜백에서의 방어 로직

플레이어에서 올라오는 상태 변경 이벤트는 비동기적이라, 현재 store 상태와 비교해서 실제로 변경된 경우에만 업데이트해야 한다:

typescript
const playerState = controls.getPlayerState?.() ?? -1;

if (playerState === -1 || playerState === 5) {
  // 아직 로드 안 됨 → 먼저 play, 그 다음 seek
  store.setState({ shouldPauseOnPlaying: true });
  controls.play();
  // onStateChange에서 isPlaying=true 감지 시 seekTo 실행 후 pause
} else {
  controls.seekTo(timeInSeconds);
  controls.pause();
}

전체 아키텍처 정리

┌─────────────────────────────────────────────────┐ │ UI Components (PlayBar, 단축키, 타임스탬프) │ │ → Store 상태 구독, Store 액션 호출 │ └──────────────────────┬──────────────────────────┘ │ ┌──────────────────────▼──────────────────────────┐ │ Player Store (Zustand) │ │ → 상태 보유 + playerControls 참조 │ │ → 액션 호출 시 controls 메서드 실행 │ └──────────────────────┬──────────────────────────┘ │ ┌──────────────────────▼──────────────────────────┐ │ PlayerControls 인터페이스 │ │ → play, pause, seekTo, setVolume, ... │ └──────────┬───────────────────────┬──────────────┘ │ │ ┌──────────▼──────────┐ ┌────────▼────────────┐ │ YouTube Controls │ │ HTML5 Controls │ │ (IFrame API 래핑) │ │ (<video> 래핑) │ └─────────────────────┘ └─────────────────────┘

이 구조의 핵심은 관심사 분리다. UI는 store만 알고, store는 인터페이스만 알고, 인터페이스 구현체만 실제 플레이어 API를 안다. 새로운 플레이어 엔진(Vimeo, 커스텀 HLS 등)을 추가할 때도 PlayerControls 구현체만 하나 더 만들면 된다.


왜 커스텀 컨트롤인가

기존 접근법과 비교하면 선택 이유가 명확해진다. 브라우저 기본 <video controls>는 제로 코스트지만 디자인 커스터마이징이 불가능하고 플랫폼마다 UI가 다르다. Video.js나 Plyr 같은 오픈소스 플레이어 라이브러리는 테마와 플러그인 생태계가 있지만, YouTube IFrame 같은 외부 플레이어와의 통합이 어렵고 번들 크기가 크다. PlayerControls 인터페이스 기반 커스텀 구현은 초기 작업량이 가장 크지만, 플레이어 엔진 교체가 자유롭고 프로젝트 고유의 UX 요구사항(타임스탬프 클릭 점프, 외부 요소 연동 등)에 완전히 대응할 수 있다.

정리

  • PlayerControls 인터페이스로 플레이어 엔진(YouTube, HTML5 등)의 API 차이를 추상화하면, UI 코드 수정 없이 엔진을 교체할 수 있다
  • Zustand Store가 상태와 controls 참조를 모두 보유하는 구조로 여러 UI 컴포넌트 간 상태 동기화와 플레이어 제어를 한 곳에서 처리한다
  • 키보드 단축키 충돌 방지(input/textarea 감지), 풀스크린 컨테이너 선택, play() 전 seekTo 방어 같은 엣지 케이스가 실제 구현의 핵심이다

관련 문서