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 { 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; retryAttempts?: number; poster?: string; autoPlay?: boolean; onEnded?: (e: React.SyntheticEvent) => 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 { 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(); // 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 => { // 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 | null>(null); const [videoObjectFit] = useState("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(null); const [isPlayerInitialized, setIsPlayerInitialized] = useState(false); const [videoCodec, setVideoCodec] = useState(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 [currentSubTrack, setCurrentSubTrack] = useState(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; // 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) => { 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) => { 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(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((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((resolve) => { // canvas.toBlob((blob: any) => resolve(blob), "image/webp", 0.7); // }); // if (!blob) return null; // return URL.createObjectURL(blob); }, []); const hideTimeout = useRef(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(null); useEffect(() => { return () => { // Component unmount cleanup if (previousSubtitleUrlRef.current) { URL.revokeObjectURL(previousSubtitleUrlRef.current); previousSubtitleUrlRef.current = null; } }; }, []); const onSelectSubtitle = useCallback( async (subtitle: SubtitlePublishedData) => { if(subtitle === null){ setCurrentSubTrack(null) if (previousSubtitleUrlRef.current) { URL.revokeObjectURL(previousSubtitleUrlRef.current); previousSubtitleUrlRef.current = null; } 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); }); } return } 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: subtitle.language, label: subtitle.language, 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(); const tracksInfo = playerRef.current?.textTracks(); const checkActiveSubtitle = () => { let activeTrack = null; const tracks = Array.from( { length: (tracksInfo as any).length }, (_, i) => (tracksInfo as any)[i] ); console.log("tracks", tracks); for (const track of tracks) { if (track.kind === 'subtitles' || track.kind === 'captions') { if (track.mode === 'showing') { activeTrack = track; break; } } } if (activeTrack) { console.log("Subtitle active:", { label: activeTrack.label, srclang: activeTrack.language || activeTrack.srclang, // srclang for native, language for VTT }); setCurrentSubTrack(activeTrack.language || activeTrack.srclang) } else { setCurrentSubTrack(null) console.log("No subtitle is currently showing"); } }; // Initial check in case one is auto-enabled checkActiveSubtitle(); // Use Video.js event system tracksInfo?.on("change", checkActiveSubtitle); }); 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 ( <> {/* */} {/* */} {isReady && ( )} ); };