656 lines
19 KiB
TypeScript

import { ReactEventHandler, Ref, RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { QortalGetMetadata } from "../../types/interfaces/resources";
import { VideoContainer, VideoElement } from "./VideoPlayer-styles";
import { useVideoPlayerHotKeys } from "./useVideoPlayerHotKeys";
import { useProgressStore, useVideoStore } from "../../state/video";
import { useVideoPlayerController } from "./useVideoPlayerController";
import { LoadingVideo } from "./LoadingVideo";
import { VideoControlsBar } from "./VideoControlsBar";
import videojs from 'video.js';
import 'video.js/dist/video-js.css';
import Player from "video.js/dist/types/player";
import { Subtitle, SubtitleManager, SubtitlePublishedData } from "./SubtitleManager";
import { base64ToBlobUrl } from "../../utils/base64";
import convert from 'srt-webvtt';
export async function srtBase64ToVttBlobUrl(base64Srt: string): Promise<string | null> {
try {
// Step 1: Convert base64 string to a Uint8Array
const binary = atob(base64Srt);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
// Step 2: Create a Blob from the Uint8Array with correct MIME type
const srtBlob = new Blob([bytes], { type: 'application/x-subrip' });
console.log('srtBlob', srtBlob)
// Step 3: Use convert() with the Blob
const vttBlobUrl: string = await convert(srtBlob);
return vttBlobUrl
} catch (error) {
console.error('Failed to convert SRT to VTT:', error);
return null;
}
}
type StretchVideoType = "contain" | "fill" | "cover" | "none" | "scale-down";
interface VideoPlayerProps {
qortalVideoResource: QortalGetMetadata;
videoRef: Ref<HTMLVideoElement>;
retryAttempts?: number;
poster?: string;
autoPlay?: boolean;
onEnded?: (e: React.SyntheticEvent<HTMLVideoElement, Event>) => void;
}
const videoStyles = {
videoContainer: { },
video: { },
};
async function loadMediaInfo(wasmPath = '/MediaInfoModule.wasm') {
const mediaInfoModule = await import('mediainfo.js');
return await mediaInfoModule.default({
format: 'JSON',
full: true,
locateFile: () => wasmPath,
});
}
async function getVideoMimeTypeFromUrl(qortalVideoResource: any): Promise<string | null> {
try {
const metadataResponse = await fetch(`/arbitrary/metadata/${qortalVideoResource.service}/${qortalVideoResource.name}/${qortalVideoResource.identifier}`)
const metadataData = await metadataResponse.json()
return metadataData?.mimeType || null
} catch (error) {
return null
}
// const mediaInfo = await loadMediaInfo();
// const chunkCache = new Map<string, Uint8Array>();
// let fileSize = 0;
// try {
// const headResp = await fetch(videoUrl, { method: 'HEAD' });
// const lengthHeader = headResp.headers.get('Content-Length');
// if (!lengthHeader) throw new Error('Missing content length');
// fileSize = parseInt(lengthHeader, 10);
// } catch (err) {
// console.error('Error fetching content length:', err);
// return null;
// }
// try {
// const rawResult = await mediaInfo.analyzeData(
// () => fileSize,
// async (chunkSize: number, offset: number): Promise<Uint8Array> => {
// const key = `${offset}:${chunkSize}`;
// if (chunkCache.has(key)) return chunkCache.get(key)!;
// const end = Math.min(fileSize - 1, offset + chunkSize - 1);
// const resp = await fetch(videoUrl, {
// headers: { Range: `bytes=${offset}-${end}` },
// });
// if (!resp.ok || (resp.status !== 206 && fileSize > chunkSize)) {
// console.warn(`Range request failed: ${resp.status}`);
// return new Uint8Array();
// }
// const blob = await resp.blob();
// const buffer = new Uint8Array(await blob.arrayBuffer());
// chunkCache.set(key, buffer);
// return buffer;
// }
// );
// const result = JSON.parse(rawResult);
// const tracks = result?.media?.track;
// const videoTrack = tracks?.find((t: any) => t['@type'] === 'Video');
// const format = videoTrack?.Format?.toLowerCase();
// switch (format) {
// case 'avc':
// case 'h264':
// case 'mpeg-4':
// case 'mp4':
// return 'video/mp4';
// case 'vp8':
// case 'vp9':
// return 'video/webm';
// case 'hevc':
// case 'h265':
// return 'video/mp4'; // still usually wrapped in MP4
// case 'matroska':
// return 'video/webm';
// default:
// return 'video/mp4'; // fallback
// }
// } catch (err) {
// console.error('Error analyzing media info:', err);
// return null;
// }
}
export const VideoPlayer = ({
videoRef,
qortalVideoResource,
retryAttempts,
poster,
autoPlay,
onEnded,
}: VideoPlayerProps) => {
const containerRef = useRef<RefObject<HTMLDivElement> | null>(null);
const [videoObjectFit] = useState<StretchVideoType>("contain");
const [isPlaying, setIsPlaying] = useState(false);
const { volume, setVolume, setPlaybackRate, playbackRate } = useVideoStore((state) => ({
volume: state.playbackSettings.volume,
setVolume: state.setVolume,
setPlaybackRate: state.setPlaybackRate,
playbackRate: state.playbackSettings.playbackRate
}));
const playerRef = useRef<Player | null>(null);
const [isPlayerInitialized, setIsPlayerInitialized] = useState(false)
const [videoCodec, setVideoCodec] = useState<null | false | string>(null)
const [isMuted, setIsMuted] = useState(false);
const { setProgress } = useProgressStore();
const [localProgress, setLocalProgress] = useState(0)
const [duration, setDuration] = useState(0)
const [isLoading, setIsLoading] = useState(true);
const [showControls, setShowControls] = useState(false)
const [isOpenSubtitleManage, setIsOpenSubtitleManage] = useState(false)
const subtitleBtnRef = useRef(null)
const {
reloadVideo,
togglePlay,
onVolumeChange,
increaseSpeed,
decreaseSpeed,
toggleMute,
isFullscreen,
toggleObjectFit,
controlsHeight,
setProgressRelative,
toggleAlwaysShowControls,
changeVolume,
startedFetch,
isReady,
resourceUrl,
startPlay,
setProgressAbsolute,
setAlwaysShowControls,
status, percentLoaded,
showControlsFullScreen,
} = useVideoPlayerController({
autoPlay,
playerRef,
qortalVideoResource,
retryAttempts,
isPlayerInitialized
});
const hotkeyHandlers = useMemo(
() => ({
reloadVideo,
togglePlay,
setProgressRelative,
toggleObjectFit,
toggleAlwaysShowControls,
increaseSpeed,
decreaseSpeed,
changeVolume,
toggleMute,
setProgressAbsolute,
setAlwaysShowControls,
}),
[
reloadVideo,
togglePlay,
setProgressRelative,
toggleObjectFit,
toggleAlwaysShowControls,
increaseSpeed,
decreaseSpeed,
changeVolume,
toggleMute,
setProgressAbsolute,
setAlwaysShowControls,
]
);
const closeSubtitleManager = useCallback(()=> {
setIsOpenSubtitleManage(false)
}, [])
const openSubtitleManager = useCallback(()=> {
setIsOpenSubtitleManage(true)
}, [])
const videoLocation = useMemo(() => {
if (!qortalVideoResource) return null;
return `${qortalVideoResource.service}-${qortalVideoResource.name}-${qortalVideoResource.identifier}`;
}, [qortalVideoResource]);
useVideoPlayerHotKeys(hotkeyHandlers);
const updateProgress = useCallback(() => {
const player = playerRef?.current;
if (!player || typeof player?.currentTime !== 'function') return;
const currentTime = player.currentTime();
if (typeof currentTime === 'number' && videoLocation && currentTime > 0.1) {
setProgress(videoLocation, currentTime);
setLocalProgress(currentTime);
}
}, [videoLocation]);
// useEffect(() => {
// const ref = videoRef as React.RefObject<HTMLVideoElement>;
// if (!ref.current) return;
// if (ref.current) {
// ref.current.volume = volume;
// }
// // Only run on mount
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, []);
const onPlay = useCallback(() => {
setIsPlaying(true);
}, [setIsPlaying]);
const onPause = useCallback(() => {
setIsPlaying(false);
}, [setIsPlaying]);
const onVolumeChangeHandler = useCallback(
(e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
try {
const video = e.currentTarget;
setVolume(video.volume);
setIsMuted(video.muted);
} catch (error) {
console.error('onVolumeChangeHandler', onVolumeChangeHandler)
}
},
[setIsMuted, setVolume]
);
const videoStylesContainer = useMemo(() => {
return {
cursor: showControls ? 'auto' : 'none',
aspectRatio: '16 / 9',
...videoStyles?.videoContainer,
};
}, [showControls]);
const videoStylesVideo = useMemo(() => {
return {
...videoStyles?.video,
objectFit: videoObjectFit,
backgroundColor: "#000000",
height: isFullscreen ? "calc(100vh - 40px)" : "100%",
width: '100%'
};
}, [videoObjectFit, isFullscreen]);
const handleEnded = useCallback(
(e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
if (onEnded) {
onEnded(e);
}
},
[onEnded]
);
const handleCanPlay = useCallback(()=> {
setIsLoading(false);
}, [setIsLoading])
useEffect(() => {
if(!isPlayerInitialized) return
const player = playerRef.current;
if (!player || typeof player.on !== 'function') return;
const handleLoadedMetadata = () => {
const duration = player.duration?.();
if (typeof duration === 'number' && !isNaN(duration)) {
setDuration(duration);
}
};
player.on('loadedmetadata', handleLoadedMetadata);
return () => {
player.off('loadedmetadata', handleLoadedMetadata);
};
}, [isPlayerInitialized]);
const enterFullscreen = () => {
const ref = containerRef?.current as any;
if (!ref) return;
if (ref.requestFullscreen && !isFullscreen) {
ref.requestFullscreen();
}
};
const exitFullscreen = () => {
if (isFullscreen) document.exitFullscreen();
};
const toggleFullscreen = () => {
isFullscreen ? exitFullscreen() : enterFullscreen();
};
const canvasRef = useRef(null)
const videoRefForCanvas = useRef<any>(null)
const extractFrames = useCallback( (time: number): void => {
// const video = videoRefForCanvas?.current;
// const canvas: any = canvasRef.current;
// if (!video || !canvas) return null;
// // Avoid unnecessary resize if already correct
// if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) {
// canvas.width = video.videoWidth;
// canvas.height = video.videoHeight;
// }
// const context = canvas.getContext("2d");
// if (!context) return null;
// // If video is already near the correct time, don't seek again
// const threshold = 0.01; // 10ms threshold
// if (Math.abs(video.currentTime - time) > threshold) {
// await new Promise<void>((resolve) => {
// const onSeeked = () => resolve();
// video.addEventListener("seeked", onSeeked, { once: true });
// video.currentTime = time;
// });
// }
// context.drawImage(video, 0, 0, canvas.width, canvas.height);
// // Use a faster method for image export (optional tradeoff)
// const blob = await new Promise<Blob | null>((resolve) => {
// canvas.toBlob((blob: any) => resolve(blob), "image/webp", 0.7);
// });
// if (!blob) return null;
// return URL.createObjectURL(blob);
}, []);
const hideTimeout = useRef<any>(null);
const resetHideTimer = () => {
setShowControls(true);
if (hideTimeout.current) clearTimeout(hideTimeout.current);
hideTimeout.current = setTimeout(() => {
setShowControls(false);
}, 2500); // 3s of inactivity
};
const handleMouseMove = () => {
resetHideTimer();
};
useEffect(() => {
resetHideTimer(); // initial show
return () => {
if (hideTimeout.current) clearTimeout(hideTimeout.current);
};
}, []);
const previousSubtitleUrlRef = useRef<string | null>(null);
useEffect(() => {
return () => {
// Component unmount cleanup
if (previousSubtitleUrlRef.current) {
URL.revokeObjectURL(previousSubtitleUrlRef.current);
previousSubtitleUrlRef.current = null;
}
};
}, []);
const onSelectSubtitle = useCallback(async (subtitle: SubtitlePublishedData)=> {
console.log('onSelectSubtitle', subtitle)
const player = playerRef.current;
if (!player || !subtitle.subtitleData || !subtitle.type) return;
// Cleanup: revoke previous Blob URL
if (previousSubtitleUrlRef.current) {
URL.revokeObjectURL(previousSubtitleUrlRef.current);
previousSubtitleUrlRef.current = null;
}
let blobUrl
if(subtitle?.type === "application/x-subrip"){
blobUrl = await srtBase64ToVttBlobUrl(subtitle.subtitleData)
} else {
blobUrl = base64ToBlobUrl(subtitle.subtitleData, subtitle.type)
}
previousSubtitleUrlRef.current = blobUrl;
const remoteTracksList = playerRef.current?.remoteTextTracks();
if (remoteTracksList) {
const toRemove: TextTrack[] = [];
// Bypass TS restrictions safely
const list = remoteTracksList as unknown as { length: number; [index: number]: TextTrack };
for (let i = 0; i < list.length; i++) {
const track = list[i];
if (track) toRemove.push(track);
}
toRemove.forEach((track) => {
playerRef.current?.removeRemoteTextTrack(track);
});
}
playerRef.current?.addRemoteTextTrack({
kind: 'subtitles',
src: blobUrl,
srclang: 'en',
label: 'English',
default: true
}, true);
// Remove all existing remote text tracks
// try {
// const remoteTracks = playerRef.current?.remoteTextTracks()?.tracks_
// if (remoteTracks && remoteTracks?.length) {
// const toRemove: TextTrack[] = [];
// for (let i = 0; i < remoteTracks.length; i++) {
// const track = remoteTracks[i];
// toRemove.push(track);
// }
// toRemove.forEach((track) => {
// console.log('removing track')
// playerRef.current?.removeRemoteTextTrack(track);
// });
// }
// } catch (error) {
// console.log('error2', error)
// }
await new Promise((res)=> {
setTimeout(() => {
res(null)
}, 1000);
})
const tracksInfo = playerRef.current?.textTracks();
console.log('tracksInfo', tracksInfo)
if (!tracksInfo) return;
const tracks = Array.from({ length: (tracksInfo as any).length }, (_, i) => (tracksInfo as any)[i]);
console.log('tracks', tracks)
for (const track of tracks) {
console.log('track', track)
if (track.kind === 'subtitles') {
track.mode = 'showing'; // force display
}
}
},[])
const handleMouseLeave = useCallback(() => {
setShowControls(false);
if (hideTimeout.current) clearTimeout(hideTimeout.current);
}, [setShowControls]);
const videoLocactionStringified = useMemo(()=> {
return JSON.stringify(qortalVideoResource)
}, [qortalVideoResource])
useEffect(() => {
if (!resourceUrl || !isReady || !videoLocactionStringified || !startPlay) return;
const resource = JSON.parse(videoLocactionStringified)
let canceled = false;
try {
const setupPlayer = async () => {
const type = await getVideoMimeTypeFromUrl(resource);
if (canceled) return;
const options = {
autoplay: true,
controls: false,
responsive: true,
fluid: true,
poster: startPlay ? "" : poster,
aspectRatio: '16:9' ,
sources: [
{
src: resourceUrl,
type: type || 'video/mp4', // fallback
},
],
};
const ref = videoRef as any;
if (!ref.current) return;
if (!playerRef.current && ref.current) {
playerRef.current = videojs(ref.current, options, () => {
setIsPlayerInitialized(true)
playerRef.current?.poster('');
playerRef.current?.playbackRate(playbackRate)
playerRef.current?.volume(volume);
playerRef.current?.play();
});
playerRef.current?.on('error', () => {
const error = playerRef.current?.error();
console.error('Video.js playback error:', error);
// Optional: display user-friendly message
});
}
};
setupPlayer();
} catch (error) {
console.error('useEffect start player', error)
}
return () => {
canceled = true;
const player = playerRef.current;
if (player && typeof player.dispose === 'function') {
try {
player.dispose();
} catch (err) {
console.error('Error disposing Video.js player:', err);
}
playerRef.current = null;
}
};
}, [isReady, resourceUrl, startPlay, poster, videoLocactionStringified]);
useEffect(() => {
if(!isPlayerInitialized) return
const player = playerRef?.current;
if (!player) return;
const handleRateChange = () => {
const newRate = player?.playbackRate();
if(newRate){
setPlaybackRate(newRate); // or any other state/action
}
};
player.on('ratechange', handleRateChange);
return () => {
player.off('ratechange', handleRateChange);
};
}, [isPlayerInitialized]);
return (
<>
{/* <video controls src={"http://127.0.0.1:22393/arbitrary/VIDEO/a-test/MYTEST2_like_MYTEST2_vid_test-parallel_cSYmIk"} ref={videoRefForCanvas} ></video> */}
<VideoContainer
tabIndex={0}
style={videoStylesContainer}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
ref={containerRef}
>
<LoadingVideo togglePlay={togglePlay} isReady={isReady} status={status} percentLoaded={percentLoaded} isLoading={isLoading} />
<VideoElement
ref={videoRef}
tabIndex={0}
className="video-js"
src={isReady && startPlay ? resourceUrl || undefined : undefined}
poster={startPlay ? "" : poster}
onTimeUpdate={updateProgress}
autoPlay={autoPlay}
onClick={togglePlay}
onEnded={handleEnded}
onCanPlay={handleCanPlay}
preload="metadata"
style={videoStylesVideo}
onPlay={onPlay}
onPause={onPause}
onVolumeChange={onVolumeChangeHandler}
controls={false}
/>
{/* <canvas ref={canvasRef} style={{ display: "none" }}></canvas> */}
{isReady && (
<VideoControlsBar subtitleBtnRef={subtitleBtnRef} playbackRate={playbackRate} increaseSpeed={hotkeyHandlers.increaseSpeed}
decreaseSpeed={hotkeyHandlers.decreaseSpeed} playerRef={playerRef} isFullScreen={isFullscreen} showControlsFullScreen={showControlsFullScreen} showControls={showControls} extractFrames={extractFrames} toggleFullscreen={toggleFullscreen} onVolumeChange={onVolumeChange} volume={volume} togglePlay={togglePlay} reloadVideo={hotkeyHandlers.reloadVideo} isPlaying={isPlaying} canPlay={true} isScreenSmall={false} controlsHeight={controlsHeight} duration={duration} progress={localProgress} openSubtitleManager={openSubtitleManager} />
)}
<SubtitleManager subtitleBtnRef={subtitleBtnRef} close={closeSubtitleManager} open={isOpenSubtitleManage} qortalMetadata={qortalVideoResource} onSelect={onSelectSubtitle} />
</VideoContainer>
</>
);
};