GhiVideo 소스 복제 — v4 작업 시작 기준
기존 GhiVideo 저장소 HEAD의 트래킹 소스 362개 파일을 복제. (node_modules·storage·빌드 산출물·대용량 미디어는 .gitignore 규칙대로 제외) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import videojs from 'video.js';
|
||||
import type Player from 'video.js/dist/types/player';
|
||||
import Hls from 'hls.js';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
|
||||
const HLS_CONFIG = {
|
||||
maxBufferLength: 30,
|
||||
maxMaxBufferLength: 600,
|
||||
maxBufferSize: 60 * 1024 * 1024,
|
||||
backBufferLength: 30,
|
||||
enableWorker: true,
|
||||
};
|
||||
|
||||
// PC 브라우저(Chrome/Firefox/Edge)가 HTML5 video로 직접 디코딩 못 하는 코덱.
|
||||
// 이런 영상은 원본 대신 (트랜스코딩된) HLS로 재생해야 한다.
|
||||
const UNSUPPORTED_CODECS = new Set(['hevc', 'h265', 'hvc1', 'hev1']);
|
||||
|
||||
export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | null>) {
|
||||
const playerRef = useRef<Player | null>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
// 현재 로컬 재생 중인 blob URL. 소스 교체/해제 시점을 결정적으로 관리(이벤트 기반 해제는 교체 시
|
||||
// 새 URL까지 조기 revoke 되는 race 가 있어 ref 로 직접 추적한다).
|
||||
const objectUrlRef = useRef<string | null>(null);
|
||||
const store = usePlayerStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || playerRef.current) return;
|
||||
|
||||
const videoEl = document.createElement('video-js');
|
||||
// fill: 컨테이너를 꽉 채움(사이니지처럼 화면 가득). object-fit:cover 로 비율 유지 크롭.
|
||||
videoEl.classList.add('vjs-big-play-centered', 'vjs-fill');
|
||||
containerRef.current.appendChild(videoEl);
|
||||
|
||||
const player = videojs(videoEl, {
|
||||
// 하단 시간 스크러버는 측점 기반 StationBar 로 대체하므로 Video.js 기본 컨트롤바 숨김
|
||||
controls: false,
|
||||
fill: true,
|
||||
responsive: true,
|
||||
playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4],
|
||||
html5: { vhs: { overrideNative: true } },
|
||||
});
|
||||
|
||||
player.on('play', () => store.setPlaying(true));
|
||||
player.on('pause', () => store.setPlaying(false));
|
||||
// 영상 첫 프레임 표시 가능 시점 — 오버레이(라벨/선)를 영상보다 먼저 그리지 않도록 게이트.
|
||||
player.on('loadstart', () => store.setVideoReady(false));
|
||||
player.on('loadeddata', () => store.setVideoReady(true));
|
||||
player.on('playing', () => store.setVideoReady(true));
|
||||
// 영상 원본 해상도 — object-fit:cover 크롭 영역 계산용(오버레이 정렬). 메타 로드 시 확정.
|
||||
const reportSize = () => store.setVideoSize(player.videoWidth() ?? 0, player.videoHeight() ?? 0);
|
||||
player.on('loadedmetadata', reportSize);
|
||||
player.on('loadeddata', reportSize);
|
||||
player.on('timeupdate', () => store.setCurrentTime(player.currentTime() ?? 0));
|
||||
player.on('durationchange', () => store.setDuration(player.duration() ?? 0));
|
||||
player.on('volumechange', () => {
|
||||
store.setVolume(player.volume() ?? 1);
|
||||
store.setMuted(player.muted() ?? false);
|
||||
});
|
||||
player.on('ratechange', () => store.setPlaybackRate(player.playbackRate() ?? 1));
|
||||
|
||||
playerRef.current = player;
|
||||
|
||||
return () => {
|
||||
hlsRef.current?.destroy();
|
||||
hlsRef.current = null;
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
}
|
||||
if (playerRef.current && !playerRef.current.isDisposed()) {
|
||||
playerRef.current.dispose();
|
||||
playerRef.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const loadLocalFile = useCallback((file: File) => {
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
// Clean up previous hls
|
||||
hlsRef.current?.destroy();
|
||||
hlsRef.current = null;
|
||||
|
||||
// 재생 중 폴더 교체 지원: 새 src 설정 후 이전 blob URL 을 해제한다.
|
||||
// (player.src() 가 새 소스를 채택한 뒤이므로 prevUrl 은 더 이상 참조되지 않아 안전)
|
||||
const prevUrl = objectUrlRef.current;
|
||||
// 교체 직전 재생 중이었으면(=드롭 교체) 새 영상도 이어서 재생. 최초 로드는 prevUrl 없음 → 자동재생 안 함.
|
||||
const wasPlaying = prevUrl != null && !player.paused();
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
objectUrlRef.current = objectUrl;
|
||||
player.src({ src: objectUrl, type: file.type || 'video/mp4' });
|
||||
store.setSource({ kind: 'local', file, objectUrl });
|
||||
store.setHlsReady(false);
|
||||
if (prevUrl) URL.revokeObjectURL(prevUrl);
|
||||
if (wasPlaying) {
|
||||
// 드롭은 사용자 제스처라 자동재생 정책에 막히지 않음. 준비되면 재생(실패는 무시).
|
||||
const p = player.play();
|
||||
if (p && typeof p.catch === 'function') p.catch(() => {});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const loadServerStream = useCallback(async (videoId: string, filename: string) => {
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
hlsRef.current?.destroy();
|
||||
hlsRef.current = null;
|
||||
// 로컬 → 서버 전환 시 이전 blob URL 해제.
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
}
|
||||
|
||||
store.setSource({ kind: 'server', videoId, filename });
|
||||
store.setHlsReady(false);
|
||||
const playRaw = () => player.src({ src: `/api/stream/${videoId}`, type: 'video/mp4' });
|
||||
|
||||
// 코덱 확인 — 브라우저가 직접 못 푸는 코덱(HEVC 등)이면 HLS로 자동 재생
|
||||
let needsHls = false;
|
||||
try {
|
||||
const meta = await fetch(`/api/meta/${videoId}`).then((r) => r.json());
|
||||
needsHls = UNSUPPORTED_CODECS.has(String(meta?.codec ?? '').toLowerCase());
|
||||
} catch {
|
||||
// meta 조회 실패 시 일단 원본으로 시도
|
||||
}
|
||||
|
||||
// await 사이에 사용자가 다른 영상을 선택했으면 중단
|
||||
const stillCurrent = () => {
|
||||
const s = usePlayerStore.getState().source;
|
||||
return s?.kind === 'server' && s.videoId === videoId;
|
||||
};
|
||||
|
||||
if (needsHls) {
|
||||
if (!stillCurrent()) return;
|
||||
const hlsId = videoId.replace(/\.[^.]+$/, '');
|
||||
const ready = await fetch(`/api/hls/${hlsId}/index.m3u8`, { method: 'HEAD' })
|
||||
.then((r) => r.ok)
|
||||
.catch(() => false);
|
||||
if (!stillCurrent()) return;
|
||||
if (ready) {
|
||||
switchToHls(videoId, 0);
|
||||
return;
|
||||
}
|
||||
// HLS 미생성 상태: 원본을 시도(에러 표시)하고, 사용자가 'HLS 변환' 버튼으로 생성하도록 유도
|
||||
}
|
||||
|
||||
// 지원 코덱이거나 HLS가 아직 없으면 원본 즉시 재생 (Range Request)
|
||||
playRaw();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const switchToHls = useCallback((videoId: string, seekTo?: number) => {
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
const hlsId = videoId.replace(/\.[^.]+$/, '');
|
||||
const hlsUrl = `/api/hls/${hlsId}/index.m3u8`;
|
||||
const savedTime = seekTo ?? player.currentTime() ?? 0;
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls(HLS_CONFIG);
|
||||
hls.loadSource(hlsUrl);
|
||||
const videoEl = player.tech(true)?.el() as HTMLVideoElement;
|
||||
hls.attachMedia(videoEl);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
player.currentTime(savedTime);
|
||||
hlsRef.current = hls;
|
||||
store.setHlsReady(true);
|
||||
});
|
||||
} else {
|
||||
player.src({ src: hlsUrl, type: 'application/x-mpegURL' });
|
||||
player.currentTime(savedTime);
|
||||
store.setHlsReady(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const getVideoElement = useCallback((): HTMLVideoElement | null => {
|
||||
return (playerRef.current?.tech(true)?.el() as HTMLVideoElement | null) ?? null;
|
||||
}, []);
|
||||
|
||||
return { playerRef, loadLocalFile, loadServerStream, switchToHls, getVideoElement };
|
||||
}
|
||||
Reference in New Issue
Block a user