개요
이번엔 스트리밍 대해서도 공부할겸 비디오 스트리밍 서버를 만들어볼겁니다. 아직 만들고있는 중이라 차근차근 하나씩 배우고 바꿔가며 만들어보겠습니다. 이번 글에서는 JavaScript를 이용해 비디오 플레이어를 만들어 보겠습니다. 아 그전에 이번 프로젝트에 대해서 설명하겠습니다.
구조
최종적으로 이런 구조로 만들어보고싶은데 가능할지는.. 잘 모르겠습니다. 하나하나 해보면서 최대한 해봐야죠.
이렇게 구성한 이유는 몇가지가 있습니다.
- 사용자가 동영상을 업로드하면 동영상을 변환하는 과정은 비동기로 처리하도록 했습니다.
- 720p 10분짜리의 영상을 변환하는데 약 3-4분 정도 걸렸고, 서버에 부담을 많이 되어 비동기로 작업을 진행했습니다.
- 비동기로 작업했을 때 3개 이상의 작업을 동시에 처리하면 서버부하가 걸리는 것을 확인했고, Thread를 제한했습니다.
- 단일 서버에서 고작 2개의 동영상만 동시에 업로드 된다는 것은 말이 안되기에 변환 작업을 담당하는 서버를 두어 동시에 여러 데이터를 처리하도록 구조를 변경했습니다.
- 유저는 업로드 '요청'을 하면 DB에서 동영상이 업로드 되었는지 여부를 지속적으로 확인하여 업로드가 완료되었는지 실패되었는지 확인할 수 있습니다.
- Storage는 아직 공부를 하지 못한 부분이지만 우선 위의 구현이 끝나면 RAID(?) 뭐 그걸로 해서 실제로 구축해보려고합니다.
적응형 스트리밍
이번 프로젝트에서는 비디오를 실행할 적응형 스트리밍에 대해서 알고 넘어가야합니다. 적응형 스트리밍은 인터넷 환경에서 끊김 없는 비디오 스트리밍을 제공하기 위한 기술입니다. 단순히 비디오를 HTTP로 다운로드를 해서 재생하는 것과는 달리 네트워크 상태에 따라 동적으로 비디오 품질을 조절할 수 있습니다.
무엇이 다른건가?
기존의 방식은 하나의 동영상 데이터가 선택되어 다운로드를 하며 플레이 하는 방식입니다.
만약 유저의 네트워크 상태가 좋지 않다면 동영상을 제대로 시청할 수 없게 될 수 있습니다. 또한 동영상의 길이가 30분이라고 했을때 유저가 1분만에 페이지를 나가거나 동영상을 넘겨 20분부터 시청한다면 시청하지 않은 비디오의 데이터가 낭비되는 문제가 생길 수 있겠죠.
이런 문제를 해결하기 위해 적응형 스트리밍 기술이 만들어졌습니다.
특징
컨텐츠 보호
적응형 스트리밍 기술은 동영상을 인코딩 하여 사용되기 때문에 원본 동영상을 사용하지 않습니다.
동영상 분할
동영상을 작은 조각으로 나누어서 사용됩니다. 이를 세그먼트(조각) 분할이라고 합니다.
- 하나의 비디오 파일을 여러 개의 작은 조각(세그먼트)으로 나눔
- 보통 2-10초 길이의 세그먼트 사용
- 각 세그먼트는 독립적으로 재생 가능
품질 인코딩
- 동일한 영상을 여러 가지 품질(비트레이트)로 인코딩
- 예: 1080p, 720p, 480p, 360p 등
- 각 품질별로 세그먼트 생성
매니페스트 파일
- 모든 품질의 세그먼트 정보를 담은 인덱스 파일
- 재생 가능한 품질 목록, 세그먼트 URL, 재생 시간 등 포함
- 프로토콜별로 다른 형식 사용 (HLS: .m3u8, DASH: .mpd)
동적 품질 전환
- 클라이언트가 네트워크 상태 모니터링
- 대역폭에 따라 적절한 품질의 세그먼트 요청
- 재생 중에도 끊김 없이 품질 전환 가능
HLS와 DASH
현재 널리 사용되는 적응형 스트리밍 프로토콜입니다.
- HLS (HTTP Live Streaming)
- Apple이 개발한 프로토콜
- iOS 기기와의 호환성이 뛰어남
- 가장 널리 사용되는 스트리밍 프로토콜
- .m3u8 재생목록 파일과 .ts 세그먼트 파일 사용
- DASH (Dynamic Adaptive Streaming over HTTP)
- MPEG에서 표준화한 프로토콜
- Netflix, YouTube 등에서 사용
- .mpd 매니페스트 파일과 .mp4 세그먼트 사용
구현하기
이제 비디오 플레이어를 만들어봅시다.
Hls.js
저는 HLS를 사용할겁니다. 웹에서 HLS 를 다루기 위해서는 Hls.js 를 사용해야 합니다.
GitHub - video-dev/hls.js: HLS.js is a JavaScript library that plays HLS in browsers with support for MSE.
HLS.js is a JavaScript library that plays HLS in browsers with support for MSE. - video-dev/hls.js
github.com
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
이렇게 넣고 시작합시다.
기본적인 사용방법은 다음과 같습니다.
if (!Hls.isSupported()) return; // Hls 지원 여부
const hls = new Hls({
debug: true, // 디버깅 여부
playlistLoadPolicy: {
default: {
// 첫 번째 바이트를 받을 때까지 기다리는 최대 시간 (10초)
maxTimeToFirstByteMs: 10000,
// 전체 로딩이 완료될 때까지 기다리는 최대 시간 (10초)
maxLoadTimeMs: 10000,
// 타임아웃 발생 시 재시도 설정
timeoutRetry: {
maxNumRetry: 2, // 최대 2번 재시도
retryDelayMs: 0, // 재시도 사이의 대기 시간 없음
maxRetryDelayMs: 0 // 최대 대기 시간 제한 없음
},
// 에러 발생 시 재시도 설정
errorRetry: {
maxNumRetry: 3, // 최대 3번 재시도
retryDelayMs: 1000, // 재시도 전 1초 대기
maxRetryDelayMs: 8000 // 재시도 간 최대 대기 시간 8초
}
}
}
});
hls.loadSource(document.querySelector('#videoPlayer'));
hls.attachMedia('/video/asdf.m3u8');
먼저 Hls 가 지원되는지 확인하고 Hls 설정해줍니다. 설정할게 없다면 new Hls() 이렇게만 해도 됩니다.
loadSource에 <video></video> 요소를 넣어주고 attachMedia에 플레이리스트 경로를 넣어주면 됩니다.
HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Custom Video Player</title>
<link rel="stylesheet" href="/css/video.css">
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
<script type="module" src="/js/video.js"></script>
</head>
<body>
<div class="video-container">
<div class="video-wrapper">
<video id="videoPlayer" crossorigin="anonymous" aria-id=""></video>
<div class="thumbnail-box">
<img class="thumbnail-image" src="" alt="">
<button type="button" id="playButton">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512">
<path d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80L0 432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"></path>
</svg>
</button>
</div>
<div class="panel disabled">
<div class="play-pause-event">
<svg class="animate-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80L0 432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"/></svg>
</div>
<div class="loading-spinner disabled">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path d="M304 48a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zm0 416a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM48 304a48 48 0 1 0 0-96 48 48 0 1 0 0 96zm464-48a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM142.9 437A48 48 0 1 0 75 369.1 48 48 0 1 0 142.9 437zm0-294.2A48 48 0 1 0 75 75a48 48 0 1 0 67.9 67.9zM369.1 437A48 48 0 1 0 437 369.1 48 48 0 1 0 369.1 437z"/>
</svg>
</div>
<div class="controller">
<div class="timeline">
<div class="buffer-progress"></div>
<div class="progress"></div>
</div>
<div class="control-box">
<div class="left-control-box">
<button class="play-pause" aria-keyshortcuts="space">
<svg class="animate-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80L0 432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"/></svg>
</button>
<div class="time-display">0:00 / 0:00</div>
<div class="volume-container">
<button class="volume-btn">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><line x1="23" y1="9" x2="17" y2="15"></line><line x1="17" y1="9" x2="23" y2="15"></line></svg>
</button>
<div class="volume-slider">
<div class="volume-progress"></div>
</div>
</div>
</div>
<div class="right-control-box">
<div class="quality-container">
<button class="quality-btn"></button>
<div class="quality-options disabled"></div>
</div>
<button class="fullscreen">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M136 32c13.3 0 24 10.7 24 24s-10.7 24-24 24L48 80l0 88c0 13.3-10.7 24-24 24s-24-10.7-24-24L0 56C0 42.7 10.7 32 24 32l112 0zM0 344c0-13.3 10.7-24 24-24s24 10.7 24 24l0 88 88 0c13.3 0 24 10.7 24 24s-10.7 24-24 24L24 480c-13.3 0-24-10.7-24-24L0 344zM424 32c13.3 0 24 10.7 24 24l0 112c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-88-88 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l112 0zM400 344c0-13.3 10.7-24 24-24s24 10.7 24 24l0 112c0 13.3-10.7 24-24 24l-112 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l88 0 0-88z"/></svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
비디오 플레이어의 디자인은 유튜브 플레이어를 참고해서 만들었습니다. <video></video> 를 덮는 panel을 두고 동영상을 조작할 수 있도록 만들었습니다.
CSS
.video-container * {
box-sizing: border-box;
}
.video-container button {
background: none;
border: none;
cursor: pointer;
color: white;
width: 30px;
height: 30px;
padding: 5px;
}
.video-container .disabled {
display: none;
}
.video-container button > svg {
pointer-events: none;
fill: white;
}
.video-container svg {
width: 20px;;
height: 20px;
}
.video-container {
max-width: 800px;
margin: 20px auto;
overflow: hidden;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.video-wrapper {
position: relative;
width: 100%;
}
#videoPlayer {
width: 100%;
display: block;
}
.thumbnail-box {
position: absolute;
top: 0; bottom: 0;
left: 0; right: 0;
}
.thumbnail-box img {
width: 100%;
height: 100%;
}
#playButton {
position: absolute;
transform: translate(-50%, -50%);
top: 50%; left: 50%;
width: 60px; height: 60px;
border-radius: 100%;
background-color: #ec2020;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
#playButton:hover {
background-color: #d81c1c;
}
.panel {
position: absolute;
width: 100%;
height: 100%;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top,
rgba(0, 0, 0, 0.7) 0%,
rgba(0, 0, 0, 0.2) 40%,
rgba(0, 0, 0, 0) 100%
);
opacity: 0;
transition: opacity 0.3s;
align-content: end;
}
.panel:hover, .panel.active {
opacity: 1;
}
.controller {
padding: 20px 10px 5px 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
.timeline {
position: relative;
height: 5px;
background: rgba(255, 255, 255, 0.3);
cursor: pointer;
border-radius: 3px;
overflow: hidden;
transition: height 0.15s ease-in-out;
}
.timeline:hover {
height: 8px;
}
/* 로드된 세그먼트를 표시할 요소 추가 */
.buffer-progress {
position: absolute;
height: 100%;
background: rgba(255, 255, 255, 0.4);
border-radius: 3px;
width: 0;
}
.progress {
position: absolute;
height: 100%;
background: #ff0000;
border-radius: 3px;
width: 0;
z-index: 1; /* 버퍼 프로그레스 위에 표시 */
}
.control-box{
display: flex;
align-items: center;
justify-content: space-between;
}
.left-control-box, .right-control-box {
display: flex;
align-items: center;
gap: 10px;
}
.volume-container {
display: flex;
align-items: center;
}
.volume-container:hover .volume-slider {
width: 60px;
}
.volume-slider {
transition: width 0.2s ease-in-out;
width: 0px;
height: 5px;
background: rgba(255, 255, 255, 0.3);
border-radius: 3px;
cursor: pointer;
}
.volume-progress {
height: 100%;
background: #fff;
border-radius: 3px;
width: 100%;
}
.time-display {
color: white;
font-size: 14px;
text-align: center;
}
.quality-container {
position: relative;
}
.quality-btn {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 5px 10px;
font-size: 14px;
width: initial !important;
}
.quality-options {
display: block;
position: absolute;
bottom: 100%;
right: 0;
background: rgba(0, 0, 0, 0.9);
border-radius: 4px;
padding: 5px 0;
margin-bottom: 5px;
z-index: 2;
}
.quality-options button {
display: block;
width: 100%;
padding: 5px 20px;
background: none;
border: none;
color: white;
cursor: pointer;
text-align: left;
white-space: nowrap;
}
.quality-options button:hover {
background: rgba(255, 255, 255, 0.1);
}
.animate-icon {
animation: iconFade 0.3s ease-out;
}
@keyframes iconFade {
from {
transform: scale(0.9);
opacity: 0.5;
}
to {
transform: scale(1);
opacity: 1;
}
}
.play-pause-event {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0;
pointer-events: none;
background: rgba(0, 0, 0, 0.4);
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 100%;
}
.play-pause-event svg {
fill: white;
}
.play-pause-event.active {
animation: scaleAndFade 0.7s ease-out forwards;
}
@keyframes scaleAndFade {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(1.7);
opacity: 0;
}
}
.loading-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
}
.loading-spinner svg {
width: 30px;
height: 30px;
fill: white;
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
JavaScript
const KEYS = {
NEXT : 'ArrowRight',
PREV : 'ArrowLeft',
PLAY_PAUSE : 'Space',
FULL_SCREEN : 'KeyF'
}
const DISABLED = 'disabled';
class Video {
constructor(videoElement, playlistUrl, panelElement) {
if (!this.#isSupported()) return;
this.video = videoElement;
this.playlistUrl = playlistUrl;
this.loadingSpinner = document.querySelector('.loading-spinner');
this.#setHls();
this.controller = new Controller(this.hls, this.video, this.loadingSpinner, panelElement);
this.#addLoadingEvent();
}
setPlayButton(thumbnailBoxElement, playButtonElement) {
this.thumbnail = thumbnailBoxElement;
this.playButton = playButtonElement;
}
#addLoadingEvent() {
// 로딩 시작
this.video.addEventListener('waiting', () => {
this.loadingSpinner.classList.remove(DISABLED);
this.controller.panel.classList.add('active');
});
// 로딩 완료
this.video.addEventListener('canplay', () => {
this.loadingSpinner.classList.add(DISABLED);
this.controller.panel.classList.remove('active');
});
}
#isSupported() {
return Hls.isSupported();
}
#setHls() {
this.hls = new Hls();
}
#initPlayButtonEvent() {
this.thumbnail.remove();
this.controller.panel.classList.remove(DISABLED);
this.play();
}
initialEventListener() {
this.controller.initialDependenciesEventListener();
}
async autoPlay() {
this.hls.loadSource(this.playlistUrl);
this.hls.attachMedia(this.video);
if (await this.play()) {
this.thumbnail.remove();
this.controller.panel.classList.remove(DISABLED);
} else {
this.playButton.addEventListener('click', () => {
this.#initPlayButtonEvent();
}, {once : true})
}
}
async play() {
return await this.controller.playPause.play();
}
pause() {
this.controller.playPause.pause();
}
}
class Controller {
constructor(hls, video, loadingSpinner, panelElement) {
this.hls = hls;
this.video = video;
this.loadingSpinner = loadingSpinner;
this.panel = panelElement;
}
setQuality(qualityBtnElement, qualityOptionsBox) {
this.quality = new Quality(qualityBtnElement, qualityOptionsBox);
// 화질 최초 선택
this.hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => {
console.log('Available qualities:', data.levels);
// 사용 가능한 화질 옵션 생성
this.quality.createQualityOptions(data.levels);
this.quality.addEventChangeQuality(this.hls, this.playPause, this.loadingSpinner);
// 현재 화질 레벨 설정
let currentLevel = this.hls.currentLevel;
this.quality.updateQualityButton(currentLevel, data.levels);
// 자동 화질 선택 모드 설정
this.hls.currentLevel = -1;
});
// 화질 변경 이벤트 처리
this.hls.on(Hls.Events.LEVEL_SWITCHED, (event, data) => {
this.quality.updateQualityButton(data.level, data.levels);
});
}
setVolume(volumeBtn, volumeSlider, volumeProgress) {
this.volume = new Volume(this.video, volumeBtn, volumeSlider, volumeProgress);
}
setPlayPauseBtn(playPauseBtnElement, playPauseEventElement) {
this.playPause = new PlayPause(this.video, this.panel, playPauseBtnElement, playPauseEventElement);
}
setFullScreen(fullscreenBtnElement) {
this.fullScreen = new FullScreen(this.video, fullscreenBtnElement);
}
setTimeline(timelineElement, progressElement, timeDisplay) {
this.timeline = new Timeline(this.video, timelineElement, progressElement, timeDisplay);
}
initialDependenciesEventListener() {
this.timeline.setDraggingEvent(this.playPause);
}
setKeyShortCuts() {
document.addEventListener('keydown', (e) => {
// 입력 필드에 포커스가 있을 때는 단축키 비활성화
if (document.activeElement.tagName === 'INPUT' ||
document.activeElement.tagName === 'TEXTAREA') {
return;
}
this.playPause.keyShortcuts(e,10, 10);
this.fullScreen.keyShortcuts(e);
})
}
}
class PlayPause {
constructor(video, panel, playPauseBtnElement, playPauseEventElement) {
this.video = video;
this.panel = panel;
this.playPauseBtn = playPauseBtnElement;
this.playPauseEvent = playPauseEventElement;
this.#addEvent();
}
#addEvent() {
this.playPauseBtn.addEventListener('click', () => {
this.#playPauseToggle();
});
this.panel.addEventListener('click', (e) => {
if (e.target !== this.panel) return;
this.#playPauseToggle();
this.#showEventAnimation();
})
// 비디오 종료 시 reload 버튼 변경
this.video.addEventListener('ended', () => {
this.playPauseBtn.innerHTML = this.getReloadSvg();
});
}
/**
* @param e
* @param increaseAmount : number n초 후로 비디오 타임라인 이동
* @param decreaseAmount : number n초 전으로 비디오 타임라인 이동
*/
keyShortcuts(e, increaseAmount, decreaseAmount) {
if (e.code === KEYS.NEXT) {
// 오른쪽 화살표 (n초 앞으로)
this.video.currentTime = Math.min(this.video.duration, this.video.currentTime + increaseAmount);
} else if (e.code === KEYS.PREV) {
// 왼쪽 화살표 (n초 뒤로)
this.video.currentTime = Math.max(0, this.video.currentTime - decreaseAmount);
} else if (e.code === KEYS.PLAY_PAUSE) {
// 시작, 일시정지
this.#playPauseToggle();
}
}
#playPauseToggle() {
this.isPaused() ? this.play() : this.pause();
}
#showEventAnimation() {
// 이전 타이머가 있다면 정리
if (this.activeTimer) {
clearTimeout(this.activeTimer);
this.playPauseEvent.classList.remove('active');
// 강제 리플로우 발생
void this.playPauseEvent.offsetWidth;
}
this.playPauseEvent.classList.add('active');
// 애니메이션이 700ms 이기 때문에 700으로 설정
this.activeTimer = setTimeout(() => {
this.playPauseEvent.classList.remove('active');
this.activeTimer = null;
}, 700);
}
async play() {
try {
let isPlay = await this.video.play();
this.playPauseBtn.innerHTML = this.getPauseSvg();
this.playPauseEvent.innerHTML = this.getPauseSvg();
return isPlay;
} catch (e) {
return false;
}
}
pause() {
this.video.pause();
this.playPauseBtn.innerHTML = this.getPlaySvg();
this.playPauseEvent.innerHTML = this.getPlaySvg();
}
isPaused() {
return this.video.paused;
}
getPauseSvg() {
return '<svg class="animate-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path d="M48 64C21.5 64 0 85.5 0 112L0 400c0 26.5 21.5 48 48 48l32 0c26.5 0 48-21.5 48-48l0-288c0-26.5-21.5-48-48-48L48 64zm192 0c-26.5 0-48 21.5-48 48l0 288c0 26.5 21.5 48 48 48l32 0c26.5 0 48-21.5 48-48l0-288c0-26.5-21.5-48-48-48l-32 0z"/></svg>';
}
getPlaySvg() {
return '<svg class="animate-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80L0 432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"/></svg>';
}
getReloadSvg() {
return '<svg class="animate-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M463.5 224H472c13.3 0 24-10.7 24-24V72c0-9.7-5.8-18.5-14.8-22.2s-19.3-1.7-26.2 5.2L413.4 96.6c-87.6-86.5-228.7-86.2-315.8 1c-87.5 87.5-87.5 229.3 0 316.8s229.3 87.5 316.8 0c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0c-62.5 62.5-163.8 62.5-226.3 0s-62.5-163.8 0-226.3c62.2-62.2 162.7-62.5 225.3-1L327 183c-6.9 6.9-8.9 17.2-5.2 26.2s12.5 14.8 22.2 14.8H463.5z"/></svg>';
}
}
class Quality {
constructor(qualityBtnElement, qualityOptionsBox) {
this.qualityBtn = qualityBtnElement;
this.qualityOptionsBox = qualityOptionsBox;
this.#addEvent();
}
createQualityOptions(levels) {
this.qualityOptionsBox.innerHTML = '';
// AUTO 옵션 추가
const autoButton = document.createElement('button');
autoButton.setAttribute('data-quality', '-1');
autoButton.textContent = 'AUTO';
this.qualityOptionsBox.appendChild(autoButton);
// 높은 화질부터 추가
for (let i = levels.length - 1; i >= 0; i--) {
const level = levels[i];
const resolution = level.height + 'p';
const button = document.createElement('button');
button.setAttribute('data-quality', i.toString());
button.textContent = resolution;
this.qualityOptionsBox.appendChild(button);
}
}
// 화질 버튼 텍스트 업데이트
updateQualityButton(level, levels) {
if (level === -1) {
this.qualityBtn.textContent = 'AUTO';
} else if (levels && level < levels.length) {
this.qualityBtn.textContent = levels[level].height + 'p';
}
this.qualityOptionsBox.classList.add(DISABLED);
}
#addEvent() {
// 선택 후 박스 감춤 이벤트
this.qualityBtn.addEventListener('click', () => {
this.qualityOptionsBox.classList.toggle(DISABLED);
})
document.addEventListener('click', (e) => {
if (e.target !== this.qualityOptionsBox && e.target !== this.qualityBtn) {
this.qualityOptionsBox.classList.add(DISABLED);
}
})
}
addEventChangeQuality(hls, playPause, loadingSpinner) {
const qualityOptions = this.qualityOptionsBox.querySelectorAll('button');
qualityOptions.forEach(button => {
button.addEventListener('click', () => {
const quality = parseInt(button.getAttribute('data-quality'));
if (quality === hls.currentLevel) {
this.qualityOptionsBox.classList.add(DISABLED);
return;
}
loadingSpinner.classList.remove(DISABLED);
if (quality === -1) {
// AUTO 모드 활성화
hls.currentLevel = -1;
hls.loadLevel = -1;
hls.config.startLevel = -1; // 자동 품질 선택
} else {
// 수동으로 특정 품질 선택
hls.currentLevel = quality;
hls.loadLevel = quality;
}
this.updateQualityButton(quality, hls.levels);
const onFragLoaded = (event, data) => {
loadingSpinner.classList.add(DISABLED);
hls.off(Hls.Events.FRAG_LOADED, onFragLoaded);
};
hls.on(Hls.Events.FRAG_LOADED, onFragLoaded);
});
});
}
}
class Volume {
constructor(video, volumeBtn, volumeSlider, volumeProgress) {
this.video = video;
this.volumeBtn = volumeBtn;
this.volumeSlider = volumeSlider;
this.volumeProgress = volumeProgress;
// 드래그를 통한 볼륨 조절 기능
this.isDraggingVolume = false;
this.addEvent();
}
#loadLocalVolumeData() {
const savedVolume = localStorage.getItem('videoVolume');
if (savedVolume !== null) {
this.video.volume = parseFloat(savedVolume);
this.#drawVolumeProgress(this.video.volume);
}
this.updateVolumeIcon(this.video.volume);
}
addEvent() {
// Initialize volume display
this.video.addEventListener('loadedmetadata', () => {
this.#loadLocalVolumeData();
});
// 볼륨 음소거 토글
this.volumeBtn.addEventListener('click', () => {
this.video.muted = !this.video.muted;
this.updateVolumeIcon(this.video.muted ? 0 : this.video.volume);
this.#drawVolumeProgress(this.video.muted ? 0 : this.video.volume);
});
// 볼륨 조절
this.volumeSlider.addEventListener('click', (e) => {
const pos = this.#getPos(e);
this.video.volume = pos;
this.#drawVolumeProgress(pos);
this.updateVolumeIcon(pos);
});
// 볼륨 슬라이더 드래그 이벤트
this.volumeSlider.addEventListener('mousedown', (e) => {
this.isDraggingVolume = true;
this.handleVolumeChange(e);
});
document.addEventListener('mousemove', (e) => {
if (this.isDraggingVolume) this.handleVolumeChange(e);
});
document.addEventListener('mouseup', () => {
this.isDraggingVolume = false;
});
}
// 볼륨 아이콘 업데이트
updateVolumeIcon(volume) {
const volumeLevel = Math.floor(volume * 100);
if (volumeLevel === 0) {
this.volumeBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M301.1 34.8C312.6 40 320 51.4 320 64l0 384c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352 64 352c-35.3 0-64-28.7-64-64l0-64c0-35.3 28.7-64 64-64l67.8 0L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3zM425 167l55 55 55-55c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-55 55 55 55c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-55-55-55 55c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l55-55-55-55c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0z"/></svg>';
} else if (volumeLevel <= 50) {
this.volumeBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M301.1 34.8C312.6 40 320 51.4 320 64l0 384c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352 64 352c-35.3 0-64-28.7-64-64l0-64c0-35.3 28.7-64 64-64l67.8 0L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3zM412.6 181.5C434.1 199.1 448 225.9 448 256s-13.9 56.9-35.4 74.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C393.1 284.4 400 271 400 256s-6.9-28.4-17.7-37.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5z"/></svg>';
} else {
this.volumeBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M333.1 34.8C312.6 40 320 51.4 320 64l0 384c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352 64 352c-35.3 0-64-28.7-64-64l0-64c0-35.3 28.7-64 64-64l67.8 0L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3zm172 72.2c43.2 35.2 70.9 88.9 70.9 149s-27.7 113.8-70.9 149c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C507.3 341.3 528 301.1 528 256s-20.7-85.3-53.2-111.8c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zm-60.5 74.5C466.1 199.1 480 225.9 480 256s-13.9 56.9-35.4 74.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C425.1 284.4 432 271 432 256s-6.9-28.4-17.7-37.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5z"/></svg>';
}
}
handleVolumeChange(e) {
let pos = this.#getPos(e);
this.video.volume = pos;
this.#drawVolumeProgress(pos);
this.updateVolumeIcon(pos);
// 볼륨 설정 저장
localStorage.setItem('videoVolume', this.video.volume);
}
#drawVolumeProgress(volume) {
this.volumeProgress.style.width = (volume * 100) + '%';
}
#getPos(e) {
const rect = this.volumeSlider.getBoundingClientRect();
let pos = (e.clientX - rect.left) / rect.width;
return Math.max(0, Math.min(1, pos)); // Clamp between 0 and 1
}
}
class Timeline {
constructor(video, timelineElement, progressElement, timeDisplay) {
this.video = video;
this.timeline = timelineElement;
this.progress = progressElement;
this.bufferProgress = this.timeline.querySelector('.buffer-progress');
this.timeDisplay = timeDisplay;
this.isDraggingProgress = false;
this.currentTimelineLocation = 0;
this.#addEvent();
}
setDraggingEvent(playPause) {
// 버퍼링 상태 업데이트
this.video.addEventListener('progress', () => {
this.#updateBufferProgress();
});
// 타임라인 드래그 이벤트
this.timeline.addEventListener('mousedown', (e) => {
this.isDraggingProgress = true;
playPause.pause();
this.currentTimelineLocation = this.#handleProgressChange(e);
});
document.addEventListener('mousemove', (e) => {
if (this.isDraggingProgress) {
this.currentTimelineLocation = this.#handleProgressChange(e);
this.percentage(this.currentTimelineLocation, this.video.duration);
}
});
document.addEventListener('mouseup', () => {
if (this.isDraggingProgress) {
this.isDraggingProgress = false;
this.video.currentTime = this.currentTimelineLocation;
playPause.play();
}
});
}
#addEvent() {
// 타임라인 클릭
this.timeline.addEventListener('click', (e) => {
this.video.currentTime = this.#handleProgressChange(e);
});
// 타임라인 업데이트
this.video.addEventListener('timeupdate', () => {
this.percentage(this.video.currentTime,this.video.duration);
});
}
#handleProgressChange(e) {
const rect = this.timeline.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
return pos * this.video.duration;
}
#updateBufferProgress() {
if (this.video.buffered.length > 0) {
const bufferedEnd = this.video.buffered.end(this.video.buffered.length - 1);
const duration = this.video.duration;
this.bufferProgress.style.width = ((bufferedEnd / duration) * 100) + '%';
}
}
percentage(current, total) {
this.timeDisplay.textContent = `${this.#formatTime(current)} / ${this.#formatTime(total)}`;
this.progress.style.width = (current / total) * 100 + '%';
}
// 시간 포맷팅
#formatTime(seconds) {
if (isNaN(seconds)) return '--:--';
const minutes = Math.floor(seconds / 60);
seconds = Math.floor(seconds % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
}
class FullScreen {
constructor(video, fullscreenBtn) {
this.video = video;
this.fullScreen = fullscreenBtn;
this.#addEvent();
}
#addEvent() {
this.fullScreen.addEventListener('click', () => this.#toggleFullScreen());
}
keyShortcuts(e) {
if (e.code === KEYS.FULL_SCREEN) {
this.#toggleFullScreen();
}
}
#toggleFullScreen() {
document.fullscreenElement ? this.#fullScreenExit() : this.#fullScreen();
}
#fullScreen() {
this.video.parentElement.requestFullscreen();
}
#fullScreenExit() {
document.exitFullscreen();
}
}
window.addEventListener('load', () => {
const videoElement = document.querySelector('#videoPlayer');
const videoId = videoElement.getAttribute('aria-id');
const playlistUrl = `/videos/${videoId}/master.m3u8`;
const video = new Video(
videoElement,
playlistUrl,
document.querySelector('.panel')
);
video.setPlayButton(
document.querySelector('.thumbnail-box'),
document.querySelector('#playButton')
);
video.controller.setQuality(
document.querySelector('.quality-btn'),
document.querySelector('.quality-options')
)
video.controller.setPlayPauseBtn(
document.querySelector('.play-pause'),
document.querySelector('.play-pause-event')
);
video.controller.setVolume(
document.querySelector('.volume-btn'),
document.querySelector('.volume-slider'),
document.querySelector('.volume-progress')
)
video.controller.setFullScreen(
document.querySelector('.fullscreen')
);
video.controller.setTimeline(
document.querySelector('.timeline'),
document.querySelector('.progress'),
document.querySelector('.time-display')
)
video.controller.setKeyShortCuts();
video.initialEventListener();
video.autoPlay();
});
JS만 거의 600줄 정도 됩니다. 최대한 깔끔하게 만들려고 Class를 만들어 관리하도록 해보았습니다. HTML, CSS는 쉬웠는데 JS가 쉽지않더라구요. Class를 만들기 전에는 800줄 정도였습니다 ㄷㄷ 더 복잡했었기도하고..
Chrome 자동재생 정책
비디오 플레이어를 만들면서 하나 알아낸 사실은 크롬에는 비디오 자동재생 정책이란게 존재한다는 것을 알았습니다. 원하지 않는 스팸성 광고를 차단하기 위한 정책인데요. 동영상이 음소거상태이거나, 이전에 유저와 상호작용을 했다거나(재생버튼 클릭으로 동영상 재생), 이전에 미디어를 소비한 것을 바탕으로 점수를 합산해 높은 점수를 받은(신뢰할 수 있는) 사이트에서는 자동재생을 허용하도록 한다고합니다.
about://media-engagement 여기에 들어가보면 그 스코어를 확인할 수 있고, 유튜브 같은 경우 신뢰할 수 있는 사이트로 분류되어 음소거 되지않은 영상을 자동재생하는 것이 가능합니다.
Chrome의 자동재생 정책 | Blog | Chrome for Developers
Chrome의 새로운 자동재생 정책을 통해 우수한 사용자 경험을 위한 권장사항을 알아보세요.
developer.chrome.com
자세한 내용은 여기에서 확인해보세요.
마치며
비디오 플레이어는 Hls만 연결해서 재생하는 것밖에 안하기 때문에 별로 중요하지 않아 자세한 설명은 하지 않고 마치겠습니다.
'FrameWork > Spring' 카테고리의 다른 글
[Spring Boot] 이벤트 주문을 만들어보자 (3) (0) | 2025.01.21 |
---|---|
[Spring Boot] 선착순 이벤트 쿠폰을 만들어보자 (2) - Redis (0) | 2025.01.19 |
[Spring Boot] 선착순 이벤트 쿠폰을 만들어보자 (1) - 동시성 이슈 (0) | 2025.01.16 |
[Spring Security][OAuth2][Resource Server] JWT 인증 (0) | 2025.01.13 |
[Spring Security] HttpSecurity 메소드를 파보자 (0) | 2025.01.07 |