switch sub

This commit is contained in:
PhilReact 2025-06-15 08:00:51 +03:00
parent d3c4a4713e
commit 864d87697c
3 changed files with 697 additions and 547 deletions

View File

@ -1,7 +1,11 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { QortalGetMetadata, QortalMetadata, Service } from "../../types/interfaces/resources";
import { import {
alpha, QortalGetMetadata,
QortalMetadata,
Service,
} from "../../types/interfaces/resources";
import {
alpha,
Box, Box,
Button, Button,
ButtonBase, ButtonBase,
@ -15,8 +19,10 @@ import {
Popover, Popover,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos'; import CheckIcon from '@mui/icons-material/Check';
import ModeEditIcon from '@mui/icons-material/ModeEdit'; import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import ArrowBackIosIcon from "@mui/icons-material/ArrowBackIos";
import ModeEditIcon from "@mui/icons-material/ModeEdit";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import { useListStore } from "../../state/lists"; import { useListStore } from "../../state/lists";
import { Resource, useResources } from "../../hooks/useResources"; import { Resource, useResources } from "../../hooks/useResources";
@ -31,14 +37,15 @@ import {
} from "react-dropzone"; } from "react-dropzone";
import { fileToBase64, objectToBase64 } from "../../utils/base64"; import { fileToBase64, objectToBase64 } from "../../utils/base64";
import { ResourceToPublish } from "../../types/qortalRequests/types"; import { ResourceToPublish } from "../../types/qortalRequests/types";
import { useListReturn } from "../../hooks/useListData"; import { useListReturn } from "../../hooks/useListData";
import { usePublish } from "../../hooks/usePublish"; import { usePublish } from "../../hooks/usePublish";
interface SubtitleManagerProps { interface SubtitleManagerProps {
qortalMetadata: QortalGetMetadata; qortalMetadata: QortalGetMetadata;
close: () => void; close: () => void;
open: boolean; open: boolean;
onSelect: (subtitle: SubtitlePublishedData)=> void; onSelect: (subtitle: SubtitlePublishedData) => void;
subtitleBtnRef: any subtitleBtnRef: any;
currentSubTrack: null | string;
} }
export interface Subtitle { export interface Subtitle {
language: string | null; language: string | null;
@ -65,32 +72,38 @@ const SubtitleManagerComponent = ({
open, open,
close, close,
onSelect, onSelect,
subtitleBtnRef subtitleBtnRef,
currentSubTrack,
}: SubtitleManagerProps) => { }: SubtitleManagerProps) => {
const [mode, setMode] = useState(1); const [mode, setMode] = useState(1);
const { lists, identifierOperations, auth } = useGlobal(); const { lists, identifierOperations, auth } = useGlobal();
const { fetchResources } = useResources(); const { fetchResources } = useResources();
// const [subtitles, setSubtitles] = useState([]) // const [subtitles, setSubtitles] = useState([])
const subtitles = useListReturn(`subs-${qortalMetadata?.service}-${qortalMetadata?.name}-${qortalMetadata?.identifier}`) const subtitles = useListReturn(
`subs-${qortalMetadata?.service}-${qortalMetadata?.name}-${qortalMetadata?.identifier}`
);
console.log('subtitles222', subtitles) console.log("subtitles222", subtitles);
const getPublishedSubtitles = useCallback(async () => { const getPublishedSubtitles = useCallback(async () => {
try { try {
const videoId = `${qortalMetadata?.service}-${qortalMetadata?.name}-${qortalMetadata?.identifier}`; const videoId = `${qortalMetadata?.service}-${qortalMetadata?.name}-${qortalMetadata?.identifier}`;
console.log('videoId', videoId) console.log("videoId", videoId);
const postIdSearch = await identifierOperations.buildSearchPrefix( const postIdSearch = await identifierOperations.buildSearchPrefix(
ENTITY_SUBTITLE, ENTITY_SUBTITLE,
videoId, videoId
); );
const searchParams = { const searchParams = {
service: SERVICE_SUBTITLE, service: SERVICE_SUBTITLE,
identifier: postIdSearch, identifier: postIdSearch,
limit: 0 limit: 0,
}; };
const res = await lists.fetchResources(searchParams, `subs-${videoId}`, "BASE64"); const res = await lists.fetchResources(
lists.addList(`subs-${videoId}`, res || []); searchParams,
console.log('resres2', res) `subs-${videoId}`,
"BASE64"
);
lists.addList(`subs-${videoId}`, res || []);
console.log("resres2", res);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
@ -104,7 +117,7 @@ const SubtitleManagerComponent = ({
) )
return; return;
getPublishedSubtitles() getPublishedSubtitles();
}, [ }, [
qortalMetadata?.identifier, qortalMetadata?.identifier,
qortalMetadata?.service, qortalMetadata?.service,
@ -120,137 +133,153 @@ const SubtitleManagerComponent = ({
// setHasMetadata(false); // setHasMetadata(false);
}; };
const publishHandler = async (subtitles: Subtitle[]) => { const publishHandler = async (subtitles: Subtitle[]) => {
try { try {
const videoId = `${qortalMetadata?.service}-${qortalMetadata?.name}-${qortalMetadata?.identifier}`; const videoId = `${qortalMetadata?.service}-${qortalMetadata?.name}-${qortalMetadata?.identifier}`;
const identifier = await identifierOperations.buildIdentifier(ENTITY_SUBTITLE, videoId); const identifier = await identifierOperations.buildIdentifier(
const name = auth?.name ENTITY_SUBTITLE,
console.log('identifier2', identifier) videoId
if(!name) return );
const resources: ResourceToPublish[] = [] const name = auth?.name;
const tempResources: {qortalMetadata: QortalMetadata, data: any}[] = [] console.log("identifier2", identifier);
for(const sub of subtitles ){ if (!name) return;
const data = { const resources: ResourceToPublish[] = [];
subtitleData: sub.base64, const tempResources: { qortalMetadata: QortalMetadata; data: any }[] = [];
language: sub.language, for (const sub of subtitles) {
filename: sub.filename, const data = {
type: sub.type subtitleData: sub.base64,
} language: sub.language,
filename: sub.filename,
const base64Data = await objectToBase64(data) type: sub.type,
const resource = { };
name,
identifier,
service: SERVICE_SUBTITLE,
base64: base64Data,
filename: sub.filename,
title: sub.language || undefined
}
resources.push(resource)
tempResources.push({
qortalMetadata: {
identifier,
service: SERVICE_SUBTITLE,
name,
size: 100,
created: Date.now()
},
data: data,
})
}
console.log('resources', resources)
await qortalRequest({ const base64Data = await objectToBase64(data);
action: 'PUBLISH_MULTIPLE_QDN_RESOURCES', const resource = {
resources name,
}) identifier,
service: SERVICE_SUBTITLE,
base64: base64Data,
filename: sub.filename,
title: sub.language || undefined,
};
resources.push(resource);
tempResources.push({
qortalMetadata: {
identifier,
service: SERVICE_SUBTITLE,
name,
size: 100,
created: Date.now(),
},
data: data,
});
}
console.log("resources", resources);
await qortalRequest({
lists.addNewResources(`subs-${qortalMetadata?.service}-${qortalMetadata?.name}-${qortalMetadata?.identifier}`, tempResources) action: "PUBLISH_MULTIPLE_QDN_RESOURCES",
} catch (error) { resources,
});
}
lists.addNewResources(
`subs-${qortalMetadata?.service}-${qortalMetadata?.name}-${qortalMetadata?.identifier}`,
tempResources
);
} catch (error) {}
};
const onBack = () => {
if (mode === 1) close();
};
const onSelectHandler = (sub: SubtitlePublishedData) => {
console.log('onSelectHandler')
onSelect(sub);
close();
}; };
const onBack = ()=> {
if(mode === 1) close()
}
const onSelectHandler = (sub: SubtitlePublishedData)=> {
onSelect(sub)
close()
}
return ( return (
<Popover <Popover
open={!!open} open={!!open}
anchorEl={subtitleBtnRef.current} anchorEl={subtitleBtnRef.current}
onClose={handleClose} onClose={handleClose}
slots={{ slots={{
transition: Fade, transition: Fade,
}} }}
slotProps={{ slotProps={{
transition: { transition: {
timeout: 200, timeout: 200,
},
paper: {
sx: {
bgcolor: alpha("#181818", 0.98),
color: "white",
opacity: 0.9,
borderRadius: 2,
boxShadow: 5,
p: 1,
minWidth: 200,
}, },
paper: { },
sx: { }}
bgcolor: alpha('#181818', 0.98), anchorOrigin={{
color: 'white', vertical: "top",
opacity: 0.9, horizontal: "center",
borderRadius: 2, }}
boxShadow: 5, transformOrigin={{
p: 1, vertical: "bottom",
minWidth: 200, horizontal: "center",
}, }}
}, >
}} <Box
anchorOrigin={{ sx={{
vertical: 'top', padding: "5px 0px 10px 0px",
horizontal: 'center', display: "flex",
}} gap: "10px",
transformOrigin={{ width: "100%",
vertical: 'bottom',
horizontal: 'center',
}} }}
> >
<Box sx={{ <ButtonBase onClick={onBack}>
padding: '5px 0px 10px 0px', <ArrowBackIosIcon
display: 'flex', sx={{
gap:'10px', fontSize: "1.15em",
width: '100%' }}
}}> />
<ButtonBase onClick={onBack}> </ButtonBase>
<ArrowBackIosIcon sx={{ <ButtonBase>
fontSize: '1.15em' <Typography
}}/> onClick={onBack}
</ButtonBase> sx={{
<ButtonBase> fontSize: "0.85rem",
<Typography onClick={onBack} sx={{ }}
fontSize: '0.85rem' >
}}>Subtitles</Typography> Subtitles
</Typography>
</ButtonBase> </ButtonBase>
<ButtonBase sx={{ <ButtonBase
marginLeft: 'auto', sx={{
marginLeft: "auto",
}}> }}
<ModeEditIcon sx={{ >
fontSize: '1.15rem' <ModeEditIcon
}} /> sx={{
</ButtonBase> fontSize: "1.15rem",
</Box> }}
<Divider /> />
{mode === 1 && ( </ButtonBase>
</Box>
<Divider />
{mode === 1 && (
<PublisherSubtitles <PublisherSubtitles
subtitles={subtitles} subtitles={subtitles}
publisherName={qortalMetadata.name} publisherName={qortalMetadata.name}
setMode={setMode} setMode={setMode}
onSelect={onSelectHandler} onSelect={onSelectHandler}
onBack={onBack} onBack={onBack}
currentSubTrack={currentSubTrack}
/> />
)} )}
{/* <Box> {/* <Box>
{[ {[
'Ambient mode', 'Ambient mode',
'Annotations', 'Annotations',
@ -274,7 +303,7 @@ console.log('identifier2', identifier)
</Typography> </Typography>
))} ))}
</Box> */} </Box> */}
</Popover> </Popover>
// <Dialog // <Dialog
// open={!!open} // open={!!open}
// fullWidth={true} // fullWidth={true}
@ -344,7 +373,8 @@ interface PublisherSubtitlesProps {
subtitles: any[]; subtitles: any[];
setMode: (val: number) => void; setMode: (val: number) => void;
onSelect: (subtitle: any) => void; onSelect: (subtitle: any) => void;
onBack: ()=> void; onBack: () => void;
currentSubTrack: string | null
} }
const PublisherSubtitles = ({ const PublisherSubtitles = ({
@ -352,28 +382,29 @@ const PublisherSubtitles = ({
subtitles, subtitles,
setMode, setMode,
onSelect, onSelect,
onBack onBack,
currentSubTrack
}: PublisherSubtitlesProps) => { }: PublisherSubtitlesProps) => {
return ( return (
<> <>
{subtitles?.map((sub) => {
{subtitles?.map((sub)=> { return (
return <Subtitle onSelect={onSelect} sub={sub} key={`${sub?.qortalMetadata?.service}-${sub?.qortalMetadata?.name}-${sub?.qortalMetadata?.identifier}`}/> <Subtitle
})} currentSubtrack={currentSubTrack}
onSelect={onSelect}
sub={sub}
key={`${sub?.qortalMetadata?.service}-${sub?.qortalMetadata?.name}-${sub?.qortalMetadata?.identifier}`}
/>
);
})}
</> </>
); );
}; };
interface PublishSubtitlesProps { interface PublishSubtitlesProps {
publishHandler: (subs: Subtitle[])=> void publishHandler: (subs: Subtitle[]) => void;
} }
const PublishSubtitles = ({ publishHandler }: PublishSubtitlesProps) => { const PublishSubtitles = ({ publishHandler }: PublishSubtitlesProps) => {
const [language, setLanguage] = useState<null | string>(null); const [language, setLanguage] = useState<null | string>(null);
const [subtitles, setSubtitles] = useState<Subtitle[]>([]); const [subtitles, setSubtitles] = useState<Subtitle[]>([]);
@ -388,7 +419,7 @@ const PublishSubtitles = ({ publishHandler }: PublishSubtitlesProps) => {
filename: file.name, filename: file.name,
size: file.size, size: file.size,
}; };
newSubtitles.push(newSubtitle) newSubtitles.push(newSubtitle);
} catch (error) { } catch (error) {
console.error("Failed to parse audio file:", error); console.error("Failed to parse audio file:", error);
} }
@ -412,19 +443,19 @@ const PublishSubtitles = ({ publishHandler }: PublishSubtitlesProps) => {
maxSize: 2 * 1024 * 1024, // 2MB maxSize: 2 * 1024 * 1024, // 2MB
}); });
const onChangeValue = (field: string, data: any, index: number) => { const onChangeValue = (field: string, data: any, index: number) => {
const sub = subtitles[index]; const sub = subtitles[index];
if (!sub) return; if (!sub) return;
const copySub = { ...sub, [field]: data }; const copySub = { ...sub, [field]: data };
setSubtitles((prev) => { setSubtitles((prev) => {
const copyPrev = [...prev]; const copyPrev = [...prev];
copyPrev[index] = copySub; copyPrev[index] = copySub;
return copyPrev; return copyPrev;
}); });
}; };
console.log('subtitles', subtitles) console.log("subtitles", subtitles);
return ( return (
<> <>
@ -438,64 +469,78 @@ console.log('subtitles', subtitles)
alignItems: "flex-start", alignItems: "flex-start",
}} }}
> >
<Box {...getRootProps()}> <Box {...getRootProps()}>
<Button <Button
sx={{ sx={{
display: 'flex', display: "flex",
gap: '10px', gap: "10px",
}} }}
variant="contained" variant="contained"
> >
<input {...getInputProps()} /> <input {...getInputProps()} />
Import subtitles Import subtitles
</Button> </Button>
</Box> </Box>
{subtitles?.map((sub, i) => { {subtitles?.map((sub, i) => {
return ( return (
<> <>
<LanguageSelect <LanguageSelect
value={sub.language} value={sub.language}
onChange={(val: string | null) => onChangeValue('language',val, i)} onChange={(val: string | null) =>
onChangeValue("language", val, i)
}
/> />
</> </>
); );
})} })}
</Box> </Box>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button <Button
onClick={()=> publishHandler(subtitles)} onClick={() => publishHandler(subtitles)}
// disabled={disableButton} // disabled={disableButton}
variant="contained" variant="contained"
> >
Publish index Publish index
</Button> </Button>
</DialogActions> </DialogActions>
</> </>
); );
}; };
interface SubProps { interface SubProps {
sub: QortalGetMetadata sub: QortalGetMetadata;
onSelect: (subtitle: Subtitle)=> void; onSelect: (subtitle: Subtitle) => void;
} currentSubtrack: null | string
const Subtitle = ({sub, onSelect}: SubProps)=> {
const {resource, isLoading } = usePublish(2, 'JSON', sub)
console.log('resource', resource)
return <Typography
onClick={()=> onSelect(resource?.data)}
sx={{
px: 2,
py: 1,
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
cursor: 'pointer',
},
}}
>
{resource?.data?.language}
</Typography>
} }
const Subtitle = ({ sub, onSelect, currentSubtrack }: SubProps) => {
const { resource, isLoading } = usePublish(2, "JSON", sub);
console.log("resource", resource);
const isSelected = currentSubtrack === resource?.data?.language
return (
<ButtonBase onClick={() => onSelect(isSelected ? null : resource?.data)} sx={{
px: 2,
py: 1,
"&:hover": {
backgroundColor: "rgba(255, 255, 255, 0.1)",
},
width: '100%',
justifyContent: 'space-between'
}}>
<Typography
>
{resource?.data?.language}
</Typography>
{isSelected ? (
<CheckIcon />
) : (
<ArrowForwardIosIcon />
)}
</ButtonBase>
);
};
export const SubtitleManager = React.memo(SubtitleManagerComponent); export const SubtitleManager = React.memo(SubtitleManagerComponent);

View File

@ -65,6 +65,7 @@ export const VideoControlsBar = ({subtitleBtnRef, showControls, playbackRate, in
opacity: showControls ? 1 : 0, opacity: showControls ? 1 : 0,
pointerEvents: showControls ? 'auto' : 'none', pointerEvents: showControls ? 'auto' : 'none',
transition: 'opacity 0.4s ease-in-out', transition: 'opacity 0.4s ease-in-out',
width: '100%'
// ...additionalStyles // ...additionalStyles
// height: controlsHeight, // height: controlsHeight,
}} }}
@ -94,7 +95,7 @@ export const VideoControlsBar = ({subtitleBtnRef, showControls, playbackRate, in
<VideoTime progress={progress} duration={duration}/> <VideoTime progress={progress} duration={duration}/>
</Box> </Box>
<Box sx={controlGroupSX}> <Box sx={{...controlGroupSX, marginLeft: 'auto'}}>
<PlaybackRate playbackRate={playbackRate} increaseSpeed={increaseSpeed} decreaseSpeed={decreaseSpeed} /> <PlaybackRate playbackRate={playbackRate} increaseSpeed={increaseSpeed} decreaseSpeed={decreaseSpeed} />
<ObjectFitButton /> <ObjectFitButton />
<IconButton ref={subtitleBtnRef} onClick={openSubtitleManager}> <IconButton ref={subtitleBtnRef} onClick={openSubtitleManager}>

View File

@ -1,4 +1,13 @@
import { ReactEventHandler, Ref, RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"; import {
ReactEventHandler,
Ref,
RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { QortalGetMetadata } from "../../types/interfaces/resources"; import { QortalGetMetadata } from "../../types/interfaces/resources";
import { VideoContainer, VideoElement } from "./VideoPlayer-styles"; import { VideoContainer, VideoElement } from "./VideoPlayer-styles";
import { useVideoPlayerHotKeys } from "./useVideoPlayerHotKeys"; import { useVideoPlayerHotKeys } from "./useVideoPlayerHotKeys";
@ -6,15 +15,21 @@ import { useProgressStore, useVideoStore } from "../../state/video";
import { useVideoPlayerController } from "./useVideoPlayerController"; import { useVideoPlayerController } from "./useVideoPlayerController";
import { LoadingVideo } from "./LoadingVideo"; import { LoadingVideo } from "./LoadingVideo";
import { VideoControlsBar } from "./VideoControlsBar"; import { VideoControlsBar } from "./VideoControlsBar";
import videojs from 'video.js'; import videojs from "video.js";
import 'video.js/dist/video-js.css'; import "video.js/dist/video-js.css";
import Player from "video.js/dist/types/player"; import Player from "video.js/dist/types/player";
import { Subtitle, SubtitleManager, SubtitlePublishedData } from "./SubtitleManager"; import {
Subtitle,
SubtitleManager,
SubtitlePublishedData,
} from "./SubtitleManager";
import { base64ToBlobUrl } from "../../utils/base64"; import { base64ToBlobUrl } from "../../utils/base64";
import convert from 'srt-webvtt'; import convert from "srt-webvtt";
export async function srtBase64ToVttBlobUrl(base64Srt: string): Promise<string | null> { export async function srtBase64ToVttBlobUrl(
base64Srt: string
): Promise<string | null> {
try { try {
// Step 1: Convert base64 string to a Uint8Array // Step 1: Convert base64 string to a Uint8Array
const binary = atob(base64Srt); const binary = atob(base64Srt);
@ -24,20 +39,19 @@ export async function srtBase64ToVttBlobUrl(base64Srt: string): Promise<string |
} }
// Step 2: Create a Blob from the Uint8Array with correct MIME type // Step 2: Create a Blob from the Uint8Array with correct MIME type
const srtBlob = new Blob([bytes], { type: 'application/x-subrip' }); const srtBlob = new Blob([bytes], { type: "application/x-subrip" });
console.log('srtBlob', srtBlob) console.log("srtBlob", srtBlob);
// Step 3: Use convert() with the Blob // Step 3: Use convert() with the Blob
const vttBlobUrl: string = await convert(srtBlob); const vttBlobUrl: string = await convert(srtBlob);
return vttBlobUrl return vttBlobUrl;
} catch (error) { } catch (error) {
console.error('Failed to convert SRT to VTT:', error); console.error("Failed to convert SRT to VTT:", error);
return null; return null;
} }
} }
type StretchVideoType = "contain" | "fill" | "cover" | "none" | "scale-down"; type StretchVideoType = "contain" | "fill" | "cover" | "none" | "scale-down";
interface VideoPlayerProps {
interface VideoPlayerProps {
qortalVideoResource: QortalGetMetadata; qortalVideoResource: QortalGetMetadata;
videoRef: Ref<HTMLVideoElement>; videoRef: Ref<HTMLVideoElement>;
retryAttempts?: number; retryAttempts?: number;
@ -47,27 +61,30 @@ type StretchVideoType = "contain" | "fill" | "cover" | "none" | "scale-down";
} }
const videoStyles = { const videoStyles = {
videoContainer: { }, videoContainer: {},
video: { }, video: {},
}; };
async function loadMediaInfo(wasmPath = '/MediaInfoModule.wasm') { async function loadMediaInfo(wasmPath = "/MediaInfoModule.wasm") {
const mediaInfoModule = await import('mediainfo.js'); const mediaInfoModule = await import("mediainfo.js");
return await mediaInfoModule.default({ return await mediaInfoModule.default({
format: 'JSON', format: "JSON",
full: true, full: true,
locateFile: () => wasmPath, locateFile: () => wasmPath,
}); });
} }
async function getVideoMimeTypeFromUrl(qortalVideoResource: any): Promise<string | null> { async function getVideoMimeTypeFromUrl(
qortalVideoResource: any
): Promise<string | null> {
try { try {
const metadataResponse = await fetch(`/arbitrary/metadata/${qortalVideoResource.service}/${qortalVideoResource.name}/${qortalVideoResource.identifier}`) const metadataResponse = await fetch(
const metadataData = await metadataResponse.json() `/arbitrary/metadata/${qortalVideoResource.service}/${qortalVideoResource.name}/${qortalVideoResource.identifier}`
return metadataData?.mimeType || null );
const metadataData = await metadataResponse.json();
return metadataData?.mimeType || null;
} catch (error) { } catch (error) {
return null return null;
} }
// const mediaInfo = await loadMediaInfo(); // const mediaInfo = await loadMediaInfo();
// const chunkCache = new Map<string, Uint8Array>(); // const chunkCache = new Map<string, Uint8Array>();
@ -147,23 +164,26 @@ export const VideoPlayer = ({
const containerRef = useRef<RefObject<HTMLDivElement> | null>(null); const containerRef = useRef<RefObject<HTMLDivElement> | null>(null);
const [videoObjectFit] = useState<StretchVideoType>("contain"); const [videoObjectFit] = useState<StretchVideoType>("contain");
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const { volume, setVolume, setPlaybackRate, playbackRate } = useVideoStore((state) => ({ const { volume, setVolume, setPlaybackRate, playbackRate } = useVideoStore(
volume: state.playbackSettings.volume, (state) => ({
setVolume: state.setVolume, volume: state.playbackSettings.volume,
setPlaybackRate: state.setPlaybackRate, setVolume: state.setVolume,
playbackRate: state.playbackSettings.playbackRate setPlaybackRate: state.setPlaybackRate,
})); playbackRate: state.playbackSettings.playbackRate,
})
);
const playerRef = useRef<Player | null>(null); const playerRef = useRef<Player | null>(null);
const [isPlayerInitialized, setIsPlayerInitialized] = useState(false) const [isPlayerInitialized, setIsPlayerInitialized] = useState(false);
const [videoCodec, setVideoCodec] = useState<null | false | string>(null) const [videoCodec, setVideoCodec] = useState<null | false | string>(null);
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const { setProgress } = useProgressStore(); const { setProgress } = useProgressStore();
const [localProgress, setLocalProgress] = useState(0) const [localProgress, setLocalProgress] = useState(0);
const [duration, setDuration] = useState(0) const [duration, setDuration] = useState(0);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [showControls, setShowControls] = useState(false) const [showControls, setShowControls] = useState(false);
const [isOpenSubtitleManage, setIsOpenSubtitleManage] = useState(false) const [isOpenSubtitleManage, setIsOpenSubtitleManage] = useState(false);
const subtitleBtnRef = useRef(null) const subtitleBtnRef = useRef(null);
const [currentSubTrack, setCurrentSubTrack] = useState<null | string>(null)
const { const {
reloadVideo, reloadVideo,
togglePlay, togglePlay,
@ -183,15 +203,15 @@ export const VideoPlayer = ({
startPlay, startPlay,
setProgressAbsolute, setProgressAbsolute,
setAlwaysShowControls, setAlwaysShowControls,
status, percentLoaded, status,
percentLoaded,
showControlsFullScreen, showControlsFullScreen,
} = useVideoPlayerController({ } = useVideoPlayerController({
autoPlay, autoPlay,
playerRef, playerRef,
qortalVideoResource, qortalVideoResource,
retryAttempts, retryAttempts,
isPlayerInitialized isPlayerInitialized,
}); });
const hotkeyHandlers = useMemo( const hotkeyHandlers = useMemo(
@ -223,16 +243,12 @@ export const VideoPlayer = ({
] ]
); );
const closeSubtitleManager = useCallback(() => {
setIsOpenSubtitleManage(false);
}, []);
const openSubtitleManager = useCallback(() => {
const closeSubtitleManager = useCallback(()=> { setIsOpenSubtitleManage(true);
setIsOpenSubtitleManage(false) }, []);
}, [])
const openSubtitleManager = useCallback(()=> {
setIsOpenSubtitleManage(true)
}, [])
const videoLocation = useMemo(() => { const videoLocation = useMemo(() => {
if (!qortalVideoResource) return null; if (!qortalVideoResource) return null;
@ -241,15 +257,15 @@ const closeSubtitleManager = useCallback(()=> {
useVideoPlayerHotKeys(hotkeyHandlers); useVideoPlayerHotKeys(hotkeyHandlers);
const updateProgress = useCallback(() => { const updateProgress = useCallback(() => {
const player = playerRef?.current; const player = playerRef?.current;
if (!player || typeof player?.currentTime !== 'function') return; if (!player || typeof player?.currentTime !== "function") return;
const currentTime = player.currentTime(); const currentTime = player.currentTime();
if (typeof currentTime === 'number' && videoLocation && currentTime > 0.1) { if (typeof currentTime === "number" && videoLocation && currentTime > 0.1) {
setProgress(videoLocation, currentTime); setProgress(videoLocation, currentTime);
setLocalProgress(currentTime); setLocalProgress(currentTime);
} }
}, [videoLocation]); }, [videoLocation]);
// useEffect(() => { // useEffect(() => {
// const ref = videoRef as React.RefObject<HTMLVideoElement>; // const ref = videoRef as React.RefObject<HTMLVideoElement>;
// if (!ref.current) return; // if (!ref.current) return;
@ -271,33 +287,30 @@ const closeSubtitleManager = useCallback(()=> {
(e: React.SyntheticEvent<HTMLVideoElement, Event>) => { (e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
try { try {
const video = e.currentTarget; const video = e.currentTarget;
setVolume(video.volume); setVolume(video.volume);
setIsMuted(video.muted); setIsMuted(video.muted);
} catch (error) { } catch (error) {
console.error('onVolumeChangeHandler', onVolumeChangeHandler) console.error("onVolumeChangeHandler", onVolumeChangeHandler);
} }
}, },
[setIsMuted, setVolume] [setIsMuted, setVolume]
); );
const videoStylesContainer = useMemo(() => { const videoStylesContainer = useMemo(() => {
return { return {
cursor: showControls ? 'auto' : 'none', cursor: showControls ? "auto" : "none",
aspectRatio: '16 / 9', aspectRatio: "16 / 9",
...videoStyles?.videoContainer, ...videoStyles?.videoContainer,
}; };
}, [showControls]); }, [showControls]);
const videoStylesVideo = useMemo(() => { const videoStylesVideo = useMemo(() => {
return { return {
...videoStyles?.video, ...videoStyles?.video,
objectFit: videoObjectFit, objectFit: videoObjectFit,
backgroundColor: "#000000", backgroundColor: "#000000",
height: isFullscreen ? "calc(100vh - 40px)" : "100%", height: isFullscreen ? "calc(100vh - 40px)" : "100%",
width: '100%' width: "100%",
}; };
}, [videoObjectFit, isFullscreen]); }, [videoObjectFit, isFullscreen]);
@ -310,29 +323,28 @@ const closeSubtitleManager = useCallback(()=> {
[onEnded] [onEnded]
); );
const handleCanPlay = useCallback(()=> { const handleCanPlay = useCallback(() => {
setIsLoading(false); setIsLoading(false);
}, [setIsLoading]) }, [setIsLoading]);
useEffect(() => { useEffect(() => {
if(!isPlayerInitialized) return if (!isPlayerInitialized) return;
const player = playerRef.current; const player = playerRef.current;
if (!player || typeof player.on !== 'function') return; if (!player || typeof player.on !== "function") return;
const handleLoadedMetadata = () => { const handleLoadedMetadata = () => {
const duration = player.duration?.(); const duration = player.duration?.();
if (typeof duration === 'number' && !isNaN(duration)) { if (typeof duration === "number" && !isNaN(duration)) {
setDuration(duration); setDuration(duration);
} }
}; };
player.on('loadedmetadata', handleLoadedMetadata); player.on("loadedmetadata", handleLoadedMetadata);
return () => {
player.off('loadedmetadata', handleLoadedMetadata);
};
}, [isPlayerInitialized]);
return () => {
player.off("loadedmetadata", handleLoadedMetadata);
};
}, [isPlayerInitialized]);
const enterFullscreen = () => { const enterFullscreen = () => {
const ref = containerRef?.current as any; const ref = containerRef?.current as any;
@ -341,8 +353,6 @@ const closeSubtitleManager = useCallback(()=> {
if (ref.requestFullscreen && !isFullscreen) { if (ref.requestFullscreen && !isFullscreen) {
ref.requestFullscreen(); ref.requestFullscreen();
} }
}; };
const exitFullscreen = () => { const exitFullscreen = () => {
@ -353,303 +363,397 @@ const closeSubtitleManager = useCallback(()=> {
isFullscreen ? exitFullscreen() : enterFullscreen(); isFullscreen ? exitFullscreen() : enterFullscreen();
}; };
const canvasRef = useRef(null) const canvasRef = useRef(null);
const videoRefForCanvas = useRef<any>(null) const videoRefForCanvas = useRef<any>(null);
const extractFrames = useCallback( (time: number): void => { const extractFrames = useCallback((time: number): void => {
// const video = videoRefForCanvas?.current; // const video = videoRefForCanvas?.current;
// const canvas: any = canvasRef.current; // const canvas: any = canvasRef.current;
// if (!video || !canvas) return null;
// if (!video || !canvas) return null; // // Avoid unnecessary resize if already correct
// if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) {
// // Avoid unnecessary resize if already correct // canvas.width = video.videoWidth;
// if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) { // 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 context = canvas.getContext("2d"); // const threshold = 0.01; // 10ms threshold
// if (!context) return null; // if (Math.abs(video.currentTime - time) > threshold) {
// await new Promise<void>((resolve) => {
// // If video is already near the correct time, don't seek again // const onSeeked = () => resolve();
// const threshold = 0.01; // 10ms threshold // video.addEventListener("seeked", onSeeked, { once: true });
// if (Math.abs(video.currentTime - time) > threshold) { // video.currentTime = time;
// await new Promise<void>((resolve) => { // });
// const onSeeked = () => resolve(); // }
// video.addEventListener("seeked", onSeeked, { once: true }); // context.drawImage(video, 0, 0, canvas.width, canvas.height);
// video.currentTime = time; // // 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);
// });
// context.drawImage(video, 0, 0, canvas.width, canvas.height); // if (!blob) return null;
// return URL.createObjectURL(blob);
// // 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 hideTimeout = useRef<any>(null);
const resetHideTimer = () => { const resetHideTimer = () => {
setShowControls(true); 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); if (hideTimeout.current) clearTimeout(hideTimeout.current);
hideTimeout.current = setTimeout(() => {
setShowControls(false);
}, 2500); // 3s of inactivity
}; };
}, []);
const previousSubtitleUrlRef = useRef<string | null>(null); const handleMouseMove = () => {
resetHideTimer();
useEffect(() => {
return () => {
// Component unmount cleanup
if (previousSubtitleUrlRef.current) {
URL.revokeObjectURL(previousSubtitleUrlRef.current);
previousSubtitleUrlRef.current = null;
}
}; };
}, []);
const onSelectSubtitle = useCallback(async (subtitle: SubtitlePublishedData)=> { useEffect(() => {
console.log('onSelectSubtitle', subtitle) resetHideTimer(); // initial show
const player = playerRef.current; return () => {
if (!player || !subtitle.subtitleData || !subtitle.type) return; if (hideTimeout.current) clearTimeout(hideTimeout.current);
};
}, []);
// Cleanup: revoke previous Blob URL const previousSubtitleUrlRef = useRef<string | null>(null);
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)
} useEffect(() => {
return () => {
previousSubtitleUrlRef.current = blobUrl; // Component unmount cleanup
if (previousSubtitleUrlRef.current) {
URL.revokeObjectURL(previousSubtitleUrlRef.current);
previousSubtitleUrlRef.current = null;
}
};
}, []);
const remoteTracksList = playerRef.current?.remoteTextTracks(); 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) { if (remoteTracksList) {
const toRemove: TextTrack[] = []; const toRemove: TextTrack[] = [];
// Bypass TS restrictions safely // Bypass TS restrictions safely
const list = remoteTracksList as unknown as { length: number; [index: number]: TextTrack }; const list = remoteTracksList as unknown as {
length: number;
[index: number]: TextTrack;
};
for (let i = 0; i < list.length; i++) { for (let i = 0; i < list.length; i++) {
const track = list[i]; const track = list[i];
if (track) toRemove.push(track); if (track) toRemove.push(track);
} }
toRemove.forEach((track) => { toRemove.forEach((track) => {
playerRef.current?.removeRemoteTextTrack(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 return
// try { }
// const remoteTracks = playerRef.current?.remoteTextTracks()?.tracks_ console.log("onSelectSubtitle", subtitle);
// if (remoteTracks && remoteTracks?.length) { const player = playerRef.current;
// const toRemove: TextTrack[] = []; if (!player || !subtitle.subtitleData || !subtitle.type) return;
// 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)=> { // Cleanup: revoke previous Blob URL
setTimeout(() => { if (previousSubtitleUrlRef.current) {
res(null) URL.revokeObjectURL(previousSubtitleUrlRef.current);
}, 1000); previousSubtitleUrlRef.current = null;
}) }
const tracksInfo = playerRef.current?.textTracks(); let blobUrl;
console.log('tracksInfo', tracksInfo) if (subtitle?.type === "application/x-subrip") {
if (!tracksInfo) return; blobUrl = await srtBase64ToVttBlobUrl(subtitle.subtitleData);
} else {
blobUrl = base64ToBlobUrl(subtitle.subtitleData, subtitle.type);
}
const tracks = Array.from({ length: (tracksInfo as any).length }, (_, i) => (tracksInfo as any)[i]); previousSubtitleUrlRef.current = blobUrl;
console.log('tracks', tracks)
for (const track of tracks) {
console.log('track', track)
if (track.kind === 'subtitles') { const remoteTracksList = playerRef.current?.remoteTextTracks();
track.mode = 'showing'; // force display
}
}
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(() => { const handleMouseLeave = useCallback(() => {
setShowControls(false); setShowControls(false);
if (hideTimeout.current) clearTimeout(hideTimeout.current); if (hideTimeout.current) clearTimeout(hideTimeout.current);
}, [setShowControls]); }, [setShowControls]);
const videoLocactionStringified = useMemo(() => {
const videoLocactionStringified = useMemo(()=> { return JSON.stringify(qortalVideoResource);
return JSON.stringify(qortalVideoResource) }, [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(() => { useEffect(() => {
if(!isPlayerInitialized) return if (!resourceUrl || !isReady || !videoLocactionStringified || !startPlay)
const player = playerRef?.current; return;
if (!player) return;
const handleRateChange = () => { const resource = JSON.parse(videoLocactionStringified);
const newRate = player?.playbackRate(); let canceled = false;
if(newRate){
setPlaybackRate(newRate); // or any other state/action 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;
player.on('ratechange', handleRateChange); 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]);
return () => { useEffect(() => {
player.off('ratechange', handleRateChange); if (!isPlayerInitialized) return;
}; const player = playerRef?.current;
}, [isPlayerInitialized]); 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 ( return (
<> <>
{/* <video controls src={"http://127.0.0.1:22393/arbitrary/VIDEO/a-test/MYTEST2_like_MYTEST2_vid_test-parallel_cSYmIk"} ref={videoRefForCanvas} ></video> */} {/* <video controls src={"http://127.0.0.1:22393/arbitrary/VIDEO/a-test/MYTEST2_like_MYTEST2_vid_test-parallel_cSYmIk"} ref={videoRefForCanvas} ></video> */}
<VideoContainer <VideoContainer
tabIndex={0} tabIndex={0}
style={videoStylesContainer} style={videoStylesContainer}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
ref={containerRef} ref={containerRef}
> >
<LoadingVideo togglePlay={togglePlay} isReady={isReady} status={status} percentLoaded={percentLoaded} isLoading={isLoading} /> <LoadingVideo
<VideoElement togglePlay={togglePlay}
ref={videoRef} isReady={isReady}
tabIndex={0} status={status}
className="video-js" 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> */}
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 && ( {isReady && (
<VideoControlsBar subtitleBtnRef={subtitleBtnRef} playbackRate={playbackRate} increaseSpeed={hotkeyHandlers.increaseSpeed} <VideoControlsBar
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} /> subtitleBtnRef={subtitleBtnRef}
)} playbackRate={playbackRate}
increaseSpeed={hotkeyHandlers.increaseSpeed}
<SubtitleManager subtitleBtnRef={subtitleBtnRef} close={closeSubtitleManager} open={isOpenSubtitleManage} qortalMetadata={qortalVideoResource} onSelect={onSelectSubtitle} /> decreaseSpeed={hotkeyHandlers.decreaseSpeed}
</VideoContainer> 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}
currentSubTrack={currentSubTrack}
/>
</VideoContainer>
</>
); );
}; };