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) { const playerRef = useRef(null); const hlsRef = useRef(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('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 (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; const objectUrl = URL.createObjectURL(file); player.src({ src: objectUrl, type: file.type || 'video/mp4' }); store.setSource({ kind: 'local', file, objectUrl }); store.setHlsReady(false); // Revoke objectUrl when video element is reset player.one('emptied', () => { URL.revokeObjectURL(objectUrl); }); // 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; 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 }; }