VideoPlayer

Data Display

Video.js 기반의 비공식 통합 동영상 플레이어.

Usage

MP4·HLS·YouTube 등 다양한 소스를 같은 컨트롤 UI로 재생해야 하는 화면 챕터 미리보기·시청 진행률·대본 패널이 필요한 학습 콘텐츠 재생 화면

import

import

import { VideoPlayer } from '@mildang/design-system/unofficial/VideoPlayer';

예제를 복사해 쓸 때 필요한 준비

@mildang/styled-system 은 이 저장소에서 Panda 가 생성하는 산출물이다. 저장소 안에서는 turbo run ship 이후 쓸 수 있고, 패키지 소비자는 자기 Panda 산출물이나 다른 레이아웃 수단으로 바꿔야 한다.

API Reference

VideoPlayer Props

Prop

Type

Default

src
필수

VideoPlayerSource

지정 안 함

aspectRatio

string

16 / 9

captions

VideoPlayerCaption[]

지정 안 함

chapters

VideoPlayerChapter[]

지정 안 함

className

string

지정 안 함

defaultSeekToSec

number

지정 안 함

initialWatchedSec

number

지정 안 함

onBackwardSeeking

() => void

지정 안 함

onForwardSeeking

() => void

지정 안 함

onFullScreenChange

(isFullScreen: boolean) => void

지정 안 함

onMuted

(muted: boolean) => void

지정 안 함

onPlayRateChange

(playRate: number) => void

지정 안 함

onQualityChange

(quality: string) => void

지정 안 함

onSeeking

(seekTo: number) => void

지정 안 함

onVolumeChange

(volume: number) => void

지정 안 함

onWatchProgressChange

(progress: VideoPlayerWatchProgress) => void

지정 안 함

playerRef

Ref<HTMLVideoElement>

지정 안 함

poster

string

지정 안 함

previewTimeline

boolean

false

thumbnails

VideoPlayerThumbnail[]

지정 안 함

thumbnailTrackUrl

string

지정 안 함

title

string

지정 안 함

toolbarSwitch

VideoPlayerToolbarSwitch

지정 안 함

watchProgressCompleteThreshold

number

0.95

VideoPlayerTranscript

Prop

Type

Default

captions
필수

VideoPlayerCaption[]

지정 안 함

videoRef
필수

RefObject<HTMLVideoElement>

지정 안 함

className

string

지정 안 함

onClose

() => void

지정 안 함

open

boolean

true

title

string

대본

예제

MP4 재생

일반 MP4 파일을 fallback video element로 재생하는 기본형이다.

import type { ComponentProps } from 'react';
import { VideoPlayer } from '@mildang/design-system/unofficial/VideoPlayer';

const sampleTranscript = `WEBVTT

00:00:00.000 --> 00:00:05.000
VideoPlayer 대본 예시입니다.

00:00:05.000 --> 00:00:10.000
자막 track cue를 읽어 대본 패널에 표시합니다.

00:00:10.000 --> 00:00:15.000
문장을 클릭하면 해당 시점으로 이동합니다.
`;

const captions = [
  {
    url: `data:text/vtt;charset=utf-8,${encodeURIComponent(sampleTranscript)}`,
    locale: 'ko',
    label: '한국어',
    default: true,
  },
];

const STORY_DEFAULT_ARGS = { ...({}), ...({
    title: 'Mux Big Buck Bunny',
    src: 'https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4',
    captions,
  }) } as ComponentProps<typeof VideoPlayer>;

export default function VideoPlayerMp4Example(props: Partial<ComponentProps<typeof VideoPlayer>> = {}) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof VideoPlayer>;
  return <VideoPlayer {...mergedProps} />;
}

HLS 스트리밍

.m3u8 HLS 스트림을 전용 media element로 재생한다.

import type { ComponentProps } from 'react';
import { VideoPlayer } from '@mildang/design-system/unofficial/VideoPlayer';

const sampleTranscript = `WEBVTT

00:00:00.000 --> 00:00:05.000
VideoPlayer 대본 예시입니다.

00:00:05.000 --> 00:00:10.000
자막 track cue를 읽어 대본 패널에 표시합니다.

00:00:10.000 --> 00:00:15.000
문장을 클릭하면 해당 시점으로 이동합니다.
`;

const captions = [
  {
    url: `data:text/vtt;charset=utf-8,${encodeURIComponent(sampleTranscript)}`,
    locale: 'ko',
    label: '한국어',
    default: true,
  },
];

const STORY_DEFAULT_ARGS = { ...({}), ...({
    title: 'Mux HLS',
    src: {
      src: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8',
      type: 'application/vnd.apple.mpegurl',
    },
    captions,
  }) } as ComponentProps<typeof VideoPlayer>;

export default function VideoPlayerHlsExample(props: Partial<ComponentProps<typeof VideoPlayer>> = {}) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof VideoPlayer>;
  return <VideoPlayer {...mergedProps} />;
}

YouTube 임베드

YouTube URL을 iframe 기반 임베드로 재생한다.

import type { ComponentProps } from 'react';
import { VideoPlayer } from '@mildang/design-system/unofficial/VideoPlayer';

const STORY_DEFAULT_ARGS = { ...({}), ...({
    title: 'YouTube',
    src: {
      src: 'https://www.youtube.com/watch?v=M7lc1UVf-VE',
      type: 'video/youtube',
    },
    captions: [],
  }) } as ComponentProps<typeof VideoPlayer>;

export default function VideoPlayerYouTubeExample(props: Partial<ComponentProps<typeof VideoPlayer>> = {}) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof VideoPlayer>;
  return <VideoPlayer {...mergedProps} />;
}

대본 패널 포함

자막 track을 읽어 문장을 클릭하면 해당 시점으로 이동하는 대본 패널을 함께 보여준다.

import type { ComponentProps } from 'react';
import { VideoPlayer } from '@mildang/design-system/unofficial/VideoPlayer';
import { useRef } from 'react';
import { css } from '@mildang/styled-system/css';
import { VideoPlayerTranscript } from '@mildang/design-system/unofficial/VideoPlayer';

const sampleTranscript = `WEBVTT

00:00:00.000 --> 00:00:05.000
VideoPlayer 대본 예시입니다.

00:00:05.000 --> 00:00:10.000
자막 track cue를 읽어 대본 패널에 표시합니다.

00:00:10.000 --> 00:00:15.000
문장을 클릭하면 해당 시점으로 이동합니다.
`;

const captions = [
  {
    url: `data:text/vtt;charset=utf-8,${encodeURIComponent(sampleTranscript)}`,
    locale: 'ko',
    label: '한국어',
    default: true,
  },
];

const STORY_DEFAULT_ARGS = { ...({}), ...({
    title: 'Mux Big Buck Bunny',
    src: 'https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4',
    captions,
  }) } as ComponentProps<typeof VideoPlayer>;

const VideoPlayerWithTranscriptPanelExampleRender = (args: ComponentProps<typeof VideoPlayer>) => {
    const videoRef = useRef<HTMLVideoElement>(null);

    return (
      <div
        className={css({
          display: 'flex',
          gap: '16',
          alignItems: 'stretch',
        })}
      >
        <VideoPlayer {...args} playerRef={videoRef} className={css({ flex: '1', minWidth: '0' })} />
        <VideoPlayerTranscript
          videoRef={videoRef}
          captions={args.captions ?? []}
          className={css({
            flex: 'none',
            width: '[calc(token(spacing.80)*4)]',
            maxHeight: '[calc(token(spacing.80)*5)]',
          })}
        />
      </div>
    );
  };

export default function VideoPlayerWithTranscriptPanelExample(props: Partial<ComponentProps<typeof VideoPlayer>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof VideoPlayer>;
  return VideoPlayerWithTranscriptPanelExampleRender(mergedProps);
}

챕터 미리보기

재생바 위에 챕터 구간과 hover 썸네일 미리보기를 함께 표시한다.

import type { ComponentProps } from 'react';
import { VideoPlayer } from '@mildang/design-system/unofficial/VideoPlayer';

const sampleTranscript = `WEBVTT

00:00:00.000 --> 00:00:05.000
VideoPlayer 대본 예시입니다.

00:00:05.000 --> 00:00:10.000
자막 track cue를 읽어 대본 패널에 표시합니다.

00:00:10.000 --> 00:00:15.000
문장을 클릭하면 해당 시점으로 이동합니다.
`;

const captions = [
  {
    url: `data:text/vtt;charset=utf-8,${encodeURIComponent(sampleTranscript)}`,
    locale: 'ko',
    label: '한국어',
    default: true,
  },
];

const chapterPreviewChapters = [
  { startTime: 0, endTime: 5, title: 'Intro' },
  { startTime: 5, endTime: 10, title: 'Escape' },
  { startTime: 10, endTime: 16, title: 'Chase' },
  { startTime: 16, endTime: 24, title: 'Forest' },
  { startTime: 24, endTime: 30, title: 'Turnaround' },
  { startTime: 30, title: 'Finale' },
];

const chapterPreviewDuration = 36;

const chapterPreviewThumbnails = Array.from({ length: chapterPreviewDuration }, (_, second) => ({
  startTime: second,
  endTime: second + 1,
  url: `https://image.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/thumbnail.jpg?time=${second}`,
  alt: `${second}초 preview`,
}));

const STORY_DEFAULT_ARGS = { ...({}), ...({
    title: 'Mux Big Buck Bunny',
    src: 'https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4',
    captions,
    chapters: chapterPreviewChapters,
    thumbnails: chapterPreviewThumbnails,
    previewTimeline: true,
    poster: 'https://image.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/thumbnail.jpg?time=1',
  }) } as ComponentProps<typeof VideoPlayer>;

export default function VideoPlayerChapterPreviewExample(props: Partial<ComponentProps<typeof VideoPlayer>> = {}) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof VideoPlayer>;
  return <VideoPlayer {...mergedProps} />;
}

시청 진행률

실제 재생한 구간만 누적해 시청 진행률을 계산하고, 지정한 위치부터 이어서 재생한다.

import type { ComponentProps } from 'react';

import { VideoPlayer } from '@mildang/design-system/unofficial/VideoPlayer';
import { useState } from 'react';
import { type VideoPlayerWatchProgress, type VideoPlayerWatchRange } from '@mildang/design-system/unofficial/VideoPlayer';
import { css } from '@mildang/styled-system/css';
import { default as Text } from '@mildang/design-system/Text';

function formatPlayerTimeWithMs(time: number) {
  if (!Number.isFinite(time) || time < 0) return '00:00.000';
  const totalMilliseconds = Math.floor(time * 1000);
  const milliseconds = totalMilliseconds % 1000;
  const totalSeconds = Math.floor(totalMilliseconds / 1000);
  const hours = Math.floor(totalSeconds / 3600);
  const minutes = Math.floor((totalSeconds % 3600) / 60);
  const seconds = totalSeconds % 60;
  const segments = hours > 0 ? [hours, minutes, seconds] : [minutes, seconds];
  return `${segments.map((segment) => String(segment).padStart(2, '0')).join(':')}.${String(milliseconds).padStart(3, '0')}`;
}

const sampleTranscript = `WEBVTT

00:00:00.000 --> 00:00:05.000
VideoPlayer 대본 예시입니다.

00:00:05.000 --> 00:00:10.000
자막 track cue를 읽어 대본 패널에 표시합니다.

00:00:10.000 --> 00:00:15.000
문장을 클릭하면 해당 시점으로 이동합니다.
`;

const captions = [
  {
    url: `data:text/vtt;charset=utf-8,${encodeURIComponent(sampleTranscript)}`,
    locale: 'ko',
    label: '한국어',
    default: true,
  },
];

function WatchRangeSegment({ duration, range }: { duration: number; range: VideoPlayerWatchRange }) {
  if (duration <= 0) return null;

  const left = (range.start / duration) * 100;
  const width = ((range.end - range.start) / duration) * 100;

  return (
    <div
      className={css({
        position: 'absolute',
        top: '0',
        bottom: '0',
        borderRadius: 'xs',
        backgroundColor: 'neutral.fill.high',
      })}
      style={{ left: `${left}%`, width: `${width}%` }}
    />
  );
}

function WatchRangeList({ label, ranges }: { label: string; ranges: VideoPlayerWatchRange[] }) {
  return (
    <div className={css({ display: 'grid', gap: '6', minWidth: '0' })}>
      <Text as="span" variant="caption-md-medium" color="neutral.text.low">
        {label}
      </Text>
      <div
        className={css({
          display: 'flex',
          flexWrap: 'wrap',
          gap: '4',
          minHeight: '24',
          alignItems: 'flex-start',
        })}
      >
        {/* 아래 "없음" 캡션: 기존 black alpha 값과 정확히 같은 hex 인 role 토큰이
            없다. 스토리 전용이라 가장 가까운 `neutral.text.low` 로 대체한다. */}
        {ranges.length === 0 ? (
          <Text as="span" variant="caption-md-medium" color="neutral.text.low">
            없음
          </Text>
        ) : (
          ranges.map((range) => (
            <Text
              key={`${range.start}-${range.end}`}
              as="span"
              variant="caption-md-medium"
              color="neutral.text.base"
              className={css({
                paddingX: '6',
                paddingY: '2',
                borderRadius: 'xs',
                backgroundColor: 'neutral.ghostBg.highest',
              })}
            >
              {formatPlayerTimeWithMs(range.start)} - {formatPlayerTimeWithMs(range.end)}
            </Text>
          ))
        )}
      </div>
    </div>
  );
}

function ProgressMetric({ label, value }: { label: string; value: string }) {
  return (
    <div className={css({ display: 'grid', gap: '4', minWidth: '0' })}>
      <Text as="span" variant="caption-md-medium" color="neutral.text.low">
        {label}
      </Text>
      <Text as="strong" variant="title-sm" color="neutral.text.base">
        {value}
      </Text>
    </div>
  );
}

const STORY_DEFAULT_ARGS = { ...({}), ...({
    title: 'Mux Big Buck Bunny',
    src: 'https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4',
    captions,
    defaultSeekToSec: 8,
  }) } as ComponentProps<typeof VideoPlayer>;

const VideoPlayerWatchProgressExampleRender = (args: ComponentProps<typeof VideoPlayer>) => {
    const [progress, setProgress] = useState<VideoPlayerWatchProgress | null>(null);

    return (
      <div className={css({ display: 'grid', gap: '16' })}>
        <VideoPlayer {...args} onWatchProgressChange={setProgress} />
        <div
          className={css({
            display: 'grid',
            gap: '12',
            padding: '16',
            border: '1px solid token(colors.neutral.border.base)',
            borderRadius: 'sm',
            backgroundColor: 'neutral.surface.low',
          })}
        >
          <div className={css({ display: 'flex', justifyContent: 'space-between', gap: '12' })}>
            <ProgressMetric label="현재 위치" value={formatPlayerTimeWithMs(progress?.currentTime ?? 0)} />
            <ProgressMetric
              label="실제 시청 시간"
              value={formatPlayerTimeWithMs(progress?.watchedTime ?? 0)}
            />
            <ProgressMetric label="남은 시간" value={formatPlayerTimeWithMs(progress?.remainingTime ?? 0)} />
            <ProgressMetric label="완료" value={progress?.isCompleted ? '완료' : '진행 중'} />
          </div>
          <div
            className={css({
              height: '8',
              position: 'relative',
              overflow: 'hidden',
              borderRadius: 'xs',
              // 미시청 구간(트랙 바닥). 이전 레거시 알파 토큰은 생성 토큰에 없어서 색이 아예
              // 안 들어갔고, 그래서 "진한 구간 = 시청 / 옅은 구간 = 미시청" 대비가 성립하지 않았다.
              // 알파 계열에서 가장 진한 `ghostBg.highest` 를 쓴다 — 위에 겹치는
              // 시청 구간(`neutral.fill.high`, 불투명)과 충분히 구분된다.
              backgroundColor: 'neutral.ghostBg.highest',
            })}
            aria-label="시청 구간 타임라인"
          >
            {(progress?.watchedRanges ?? []).map((range) => (
              <WatchRangeSegment
                key={`${range.start}-${range.end}`}
                duration={progress?.duration ?? 0}
                range={range}
              />
            ))}
          </div>
          <div className={css({ display: 'flex', justifyContent: 'space-between', gap: '12' })}>
            <Text as="p" variant="caption-md-medium" color="neutral.text.low">
              {progress?.watchedPercent.toFixed(3) ?? '0.000'}% 실제 시청
            </Text>
            <Text as="p" variant="caption-md-medium" color="neutral.text.low">
              진한 구간: 시청 / 옅은 구간: 미시청
            </Text>
          </div>
          <div
            className={css({ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '12' })}
          >
            <WatchRangeList label="본 구간" ranges={progress?.watchedRanges ?? []} />
            <WatchRangeList label="안 본 구간" ranges={progress?.unwatchedRanges ?? []} />
          </div>
        </div>
      </div>
    );
  };

export default function VideoPlayerWatchProgressExample(props: Partial<ComponentProps<typeof VideoPlayer>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof VideoPlayer>;
  return VideoPlayerWatchProgressExampleRender(mergedProps);
}