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
VideoPlayerSource
지정 안 함
string
16 / 9
VideoPlayerCaption[]
지정 안 함
VideoPlayerChapter[]
지정 안 함
string
지정 안 함
number
지정 안 함
number
지정 안 함
() => void
지정 안 함
() => void
지정 안 함
(isFullScreen: boolean) => void
지정 안 함
(muted: boolean) => void
지정 안 함
(playRate: number) => void
지정 안 함
(quality: string) => void
지정 안 함
(seekTo: number) => void
지정 안 함
(volume: number) => void
지정 안 함
(progress: VideoPlayerWatchProgress) => void
지정 안 함
Ref<HTMLVideoElement>
지정 안 함
string
지정 안 함
boolean
false
VideoPlayerThumbnail[]
지정 안 함
string
지정 안 함
string
지정 안 함
VideoPlayerToolbarSwitch
지정 안 함
number
0.95
VideoPlayerTranscript
Prop
Type
Default
VideoPlayerCaption[]
지정 안 함
RefObject<HTMLVideoElement>
지정 안 함
string
지정 안 함
() => void
지정 안 함
boolean
true
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);
}