mirror of
https://github.com/Qortal/qapp-core.git
synced 2025-06-19 03:11:20 +00:00
656 lines
19 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
};
|