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,5 +1,9 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { QortalGetMetadata, QortalMetadata, Service } from "../../types/interfaces/resources"; import {
QortalGetMetadata,
QortalMetadata,
Service,
} from "../../types/interfaces/resources";
import { import {
alpha, alpha,
Box, Box,
@ -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";
@ -38,7 +44,8 @@ interface SubtitleManagerProps {
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(
searchParams,
`subs-${videoId}`,
"BASE64"
);
lists.addList(`subs-${videoId}`, res || []); lists.addList(`subs-${videoId}`, res || []);
console.log('resres2', 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,67 +133,71 @@ 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);
if (!name) return;
const resources: ResourceToPublish[] = [];
const tempResources: { qortalMetadata: QortalMetadata; data: any }[] = [];
for (const sub of subtitles) { for (const sub of subtitles) {
const data = { const data = {
subtitleData: sub.base64, subtitleData: sub.base64,
language: sub.language, language: sub.language,
filename: sub.filename, filename: sub.filename,
type: sub.type type: sub.type,
} };
const base64Data = await objectToBase64(data) const base64Data = await objectToBase64(data);
const resource = { const resource = {
name, name,
identifier, identifier,
service: SERVICE_SUBTITLE, service: SERVICE_SUBTITLE,
base64: base64Data, base64: base64Data,
filename: sub.filename, filename: sub.filename,
title: sub.language || undefined title: sub.language || undefined,
} };
resources.push(resource) resources.push(resource);
tempResources.push({ tempResources.push({
qortalMetadata: { qortalMetadata: {
identifier, identifier,
service: SERVICE_SUBTITLE, service: SERVICE_SUBTITLE,
name, name,
size: 100, size: 100,
created: Date.now() created: Date.now(),
}, },
data: data, data: data,
}) });
} }
console.log('resources', resources) console.log("resources", resources);
await qortalRequest({ await qortalRequest({
action: 'PUBLISH_MULTIPLE_QDN_RESOURCES', action: "PUBLISH_MULTIPLE_QDN_RESOURCES",
resources resources,
}) });
lists.addNewResources(
lists.addNewResources(`subs-${qortalMetadata?.service}-${qortalMetadata?.name}-${qortalMetadata?.identifier}`, tempResources) `subs-${qortalMetadata?.service}-${qortalMetadata?.name}-${qortalMetadata?.identifier}`,
} catch (error) { tempResources
);
} } catch (error) {}
}; };
const onBack = () => { const onBack = () => {
if(mode === 1) close() if (mode === 1) close();
} };
const onSelectHandler = (sub: SubtitlePublishedData) => { const onSelectHandler = (sub: SubtitlePublishedData) => {
onSelect(sub) console.log('onSelectHandler')
close() onSelect(sub);
} close();
};
return ( return (
<Popover <Popover
open={!!open} open={!!open}
@ -195,8 +212,8 @@ console.log('identifier2', identifier)
}, },
paper: { paper: {
sx: { sx: {
bgcolor: alpha('#181818', 0.98), bgcolor: alpha("#181818", 0.98),
color: 'white', color: "white",
opacity: 0.9, opacity: 0.9,
borderRadius: 2, borderRadius: 2,
boxShadow: 5, boxShadow: 5,
@ -206,38 +223,49 @@ console.log('identifier2', identifier)
}, },
}} }}
anchorOrigin={{ anchorOrigin={{
vertical: 'top', vertical: "top",
horizontal: 'center', horizontal: "center",
}} }}
transformOrigin={{ transformOrigin={{
vertical: 'bottom', vertical: "bottom",
horizontal: 'center', horizontal: "center",
}}
>
<Box
sx={{
padding: "5px 0px 10px 0px",
display: "flex",
gap: "10px",
width: "100%",
}} }}
> >
<Box sx={{
padding: '5px 0px 10px 0px',
display: 'flex',
gap:'10px',
width: '100%'
}}>
<ButtonBase onClick={onBack}> <ButtonBase onClick={onBack}>
<ArrowBackIosIcon sx={{ <ArrowBackIosIcon
fontSize: '1.15em' sx={{
}}/> fontSize: "1.15em",
}}
/>
</ButtonBase> </ButtonBase>
<ButtonBase> <ButtonBase>
<Typography onClick={onBack} sx={{ <Typography
fontSize: '0.85rem' onClick={onBack}
}}>Subtitles</Typography> sx={{
fontSize: "0.85rem",
}}
>
Subtitles
</Typography>
</ButtonBase> </ButtonBase>
<ButtonBase sx={{ <ButtonBase
marginLeft: 'auto', sx={{
marginLeft: "auto",
}}> }}
<ModeEditIcon sx={{ >
fontSize: '1.15rem' <ModeEditIcon
}} /> sx={{
fontSize: "1.15rem",
}}
/>
</ButtonBase> </ButtonBase>
</Box> </Box>
<Divider /> <Divider />
@ -248,6 +276,7 @@ console.log('identifier2', identifier)
setMode={setMode} setMode={setMode}
onSelect={onSelectHandler} onSelect={onSelectHandler}
onBack={onBack} onBack={onBack}
currentSubTrack={currentSubTrack}
/> />
)} )}
{/* <Box> {/* <Box>
@ -345,6 +374,7 @@ interface PublisherSubtitlesProps {
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 <Subtitle onSelect={onSelect} sub={sub} key={`${sub?.qortalMetadata?.service}-${sub?.qortalMetadata?.name}-${sub?.qortalMetadata?.identifier}`}/> return (
<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);
} }
@ -424,7 +455,7 @@ const onChangeValue = (field: string, data: any, index: number) => {
return copyPrev; return copyPrev;
}); });
}; };
console.log('subtitles', subtitles) console.log("subtitles", subtitles);
return ( return (
<> <>
@ -441,8 +472,8 @@ console.log('subtitles', subtitles)
<Box {...getRootProps()}> <Box {...getRootProps()}>
<Button <Button
sx={{ sx={{
display: 'flex', display: "flex",
gap: '10px', gap: "10px",
}} }}
variant="contained" variant="contained"
> >
@ -455,7 +486,9 @@ console.log('subtitles', subtitles)
<> <>
<LanguageSelect <LanguageSelect
value={sub.language} value={sub.language}
onChange={(val: string | null) => onChangeValue('language',val, i)} onChange={(val: string | null) =>
onChangeValue("language", val, i)
}
/> />
</> </>
); );
@ -476,26 +509,38 @@ console.log('subtitles', subtitles)
}; };
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 Subtitle = ({ sub, onSelect, currentSubtrack }: SubProps) => {
const {resource, isLoading } = usePublish(2, 'JSON', sub) const { resource, isLoading } = usePublish(2, "JSON", sub);
console.log('resource', resource) console.log("resource", resource);
return <Typography const isSelected = currentSubtrack === resource?.data?.language
onClick={()=> onSelect(resource?.data)} return (
<ButtonBase onClick={() => onSelect(isSelected ? null : resource?.data)} sx={{
sx={{
px: 2, px: 2,
py: 1, py: 1,
'&:hover': { "&:hover": {
backgroundColor: 'rgba(255, 255, 255, 0.1)', backgroundColor: "rgba(255, 255, 255, 0.1)",
cursor: 'pointer',
}, },
}} width: '100%',
justifyContent: 'space-between'
}}>
<Typography
> >
{resource?.data?.language} {resource?.data?.language}
</Typography> </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,19 +39,18 @@ 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>;
@ -51,23 +65,26 @@ const videoStyles = {
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(
(state) => ({
volume: state.playbackSettings.volume, volume: state.playbackSettings.volume,
setVolume: state.setVolume, setVolume: state.setVolume,
setPlaybackRate: state.setPlaybackRate, setPlaybackRate: state.setPlaybackRate,
playbackRate: state.playbackSettings.playbackRate 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(() => { const closeSubtitleManager = useCallback(() => {
setIsOpenSubtitleManage(false) setIsOpenSubtitleManage(false);
}, []) }, []);
const openSubtitleManager = useCallback(() => { const openSubtitleManager = useCallback(() => {
setIsOpenSubtitleManage(true) setIsOpenSubtitleManage(true);
}, []) }, []);
const videoLocation = useMemo(() => { const videoLocation = useMemo(() => {
if (!qortalVideoResource) return null; if (!qortalVideoResource) return null;
@ -242,10 +258,10 @@ const closeSubtitleManager = useCallback(()=> {
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);
} }
@ -274,30 +290,27 @@ const closeSubtitleManager = useCallback(()=> {
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]);
@ -312,28 +325,27 @@ const closeSubtitleManager = useCallback(()=> {
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 () => { return () => {
player.off('loadedmetadata', handleLoadedMetadata); player.off("loadedmetadata", handleLoadedMetadata);
}; };
}, [isPlayerInitialized]); }, [isPlayerInitialized]);
const enterFullscreen = () => { const enterFullscreen = () => {
const ref = containerRef?.current as any; const ref = containerRef?.current as any;
if (!ref) return; if (!ref) return;
@ -341,8 +353,6 @@ const closeSubtitleManager = useCallback(()=> {
if (ref.requestFullscreen && !isFullscreen) { if (ref.requestFullscreen && !isFullscreen) {
ref.requestFullscreen(); ref.requestFullscreen();
} }
}; };
const exitFullscreen = () => { const exitFullscreen = () => {
@ -353,23 +363,19 @@ 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 // // Avoid unnecessary resize if already correct
// if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) { // if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) {
// canvas.width = video.videoWidth; // canvas.width = video.videoWidth;
// canvas.height = video.videoHeight; // canvas.height = video.videoHeight;
// } // }
// const context = canvas.getContext("2d"); // const context = canvas.getContext("2d");
// if (!context) return null; // if (!context) return null;
// // If video is already near the correct time, don't seek again // // If video is already near the correct time, don't seek again
// const threshold = 0.01; // 10ms threshold // const threshold = 0.01; // 10ms threshold
// if (Math.abs(video.currentTime - time) > threshold) { // if (Math.abs(video.currentTime - time) > threshold) {
@ -379,23 +385,17 @@ const extractFrames = useCallback( (time: number): void => {
// video.currentTime = time; // video.currentTime = time;
// }); // });
// } // }
// context.drawImage(video, 0, 0, canvas.width, canvas.height); // context.drawImage(video, 0, 0, canvas.width, canvas.height);
// // Use a faster method for image export (optional tradeoff) // // Use a faster method for image export (optional tradeoff)
// const blob = await new Promise<Blob | null>((resolve) => { // const blob = await new Promise<Blob | null>((resolve) => {
// canvas.toBlob((blob: any) => resolve(blob), "image/webp", 0.7); // canvas.toBlob((blob: any) => resolve(blob), "image/webp", 0.7);
// }); // });
// if (!blob) return null; // if (!blob) return null;
// return URL.createObjectURL(blob); // 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); if (hideTimeout.current) clearTimeout(hideTimeout.current);
@ -427,33 +427,24 @@ useEffect(() => {
}; };
}, []); }, []);
const onSelectSubtitle = useCallback(async (subtitle: SubtitlePublishedData)=> { const onSelectSubtitle = useCallback(
console.log('onSelectSubtitle', subtitle) async (subtitle: SubtitlePublishedData) => {
const player = playerRef.current; if(subtitle === null){
if (!player || !subtitle.subtitleData || !subtitle.type) return; setCurrentSubTrack(null)
// Cleanup: revoke previous Blob URL
if (previousSubtitleUrlRef.current) { if (previousSubtitleUrlRef.current) {
URL.revokeObjectURL(previousSubtitleUrlRef.current); URL.revokeObjectURL(previousSubtitleUrlRef.current);
previousSubtitleUrlRef.current = null; 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(); 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];
@ -464,13 +455,57 @@ if (remoteTracksList) {
playerRef.current?.removeRemoteTextTrack(track); playerRef.current?.removeRemoteTextTrack(track);
}); });
} }
playerRef.current?.addRemoteTextTrack({
kind: 'subtitles', 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, src: blobUrl,
srclang: 'en', srclang: subtitle.language,
label: 'English', label: subtitle.language,
default: true default: true,
}, true); },
true
);
// Remove all existing remote text tracks // Remove all existing remote text tracks
// try { // try {
@ -492,40 +527,43 @@ if (remoteTracksList) {
await new Promise((res) => { await new Promise((res) => {
setTimeout(() => { setTimeout(() => {
res(null) res(null);
}, 1000); }, 1000);
}) });
const tracksInfo = playerRef.current?.textTracks(); const tracksInfo = playerRef.current?.textTracks();
console.log('tracksInfo', tracksInfo) console.log("tracksInfo", tracksInfo);
if (!tracksInfo) return; if (!tracksInfo) return;
const tracks = Array.from({ length: (tracksInfo as any).length }, (_, i) => (tracksInfo as any)[i]); const tracks = Array.from(
console.log('tracks', tracks) { length: (tracksInfo as any).length },
(_, i) => (tracksInfo as any)[i]
);
console.log("tracks", tracks);
for (const track of tracks) { for (const track of tracks) {
console.log('track', track) console.log("track", track);
if (track.kind === 'subtitles') { if (track.kind === "subtitles") {
track.mode = 'showing'; // force display 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(() => { useEffect(() => {
if (!resourceUrl || !isReady || !videoLocactionStringified || !startPlay) return; if (!resourceUrl || !isReady || !videoLocactionStringified || !startPlay)
return;
const resource = JSON.parse(videoLocactionStringified) const resource = JSON.parse(videoLocactionStringified);
let canceled = false; let canceled = false;
try { try {
@ -539,11 +577,11 @@ useEffect(() => {
responsive: true, responsive: true,
fluid: true, fluid: true,
poster: startPlay ? "" : poster, poster: startPlay ? "" : poster,
aspectRatio: '16:9' , aspectRatio: "16:9",
sources: [ sources: [
{ {
src: resourceUrl, src: resourceUrl,
type: type || 'video/mp4', // fallback type: type || "video/mp4", // fallback
}, },
], ],
}; };
@ -552,37 +590,72 @@ useEffect(() => {
if (!playerRef.current && ref.current) { if (!playerRef.current && ref.current) {
playerRef.current = videojs(ref.current, options, () => { playerRef.current = videojs(ref.current, options, () => {
setIsPlayerInitialized(true) setIsPlayerInitialized(true);
playerRef.current?.poster(''); playerRef.current?.poster("");
playerRef.current?.playbackRate(playbackRate) playerRef.current?.playbackRate(playbackRate);
playerRef.current?.volume(volume); playerRef.current?.volume(volume);
playerRef.current?.play(); 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
}); });
playerRef.current?.on('error', () => { 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(); const error = playerRef.current?.error();
console.error('Video.js playback error:', error); console.error("Video.js playback error:", error);
// Optional: display user-friendly message // Optional: display user-friendly message
}); });
} }
}; };
setupPlayer(); setupPlayer();
} catch (error) { } catch (error) {
console.error('useEffect start player', error) console.error("useEffect start player", error);
} }
return () => { return () => {
canceled = true; canceled = true;
const player = playerRef.current; const player = playerRef.current;
if (player && typeof player.dispose === 'function') { if (player && typeof player.dispose === "function") {
try { try {
player.dispose(); player.dispose();
} catch (err) { } catch (err) {
console.error('Error disposing Video.js player:', err); console.error("Error disposing Video.js player:", err);
} }
playerRef.current = null; playerRef.current = null;
} }
@ -590,7 +663,7 @@ useEffect(() => {
}, [isReady, resourceUrl, startPlay, poster, videoLocactionStringified]); }, [isReady, resourceUrl, startPlay, poster, videoLocactionStringified]);
useEffect(() => { useEffect(() => {
if(!isPlayerInitialized) return if (!isPlayerInitialized) return;
const player = playerRef?.current; const player = playerRef?.current;
if (!player) return; if (!player) return;
@ -601,10 +674,10 @@ useEffect(() => {
} }
}; };
player.on('ratechange', handleRateChange); player.on("ratechange", handleRateChange);
return () => { return () => {
player.off('ratechange', handleRateChange); player.off("ratechange", handleRateChange);
}; };
}, [isPlayerInitialized]); }, [isPlayerInitialized]);
@ -619,12 +692,17 @@ useEffect(() => {
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
ref={containerRef} ref={containerRef}
> >
<LoadingVideo togglePlay={togglePlay} isReady={isReady} status={status} percentLoaded={percentLoaded} isLoading={isLoading} /> <LoadingVideo
togglePlay={togglePlay}
isReady={isReady}
status={status}
percentLoaded={percentLoaded}
isLoading={isLoading}
/>
<VideoElement <VideoElement
ref={videoRef} ref={videoRef}
tabIndex={0} tabIndex={0}
className="video-js" className="video-js"
src={isReady && startPlay ? resourceUrl || undefined : undefined} src={isReady && startPlay ? resourceUrl || undefined : undefined}
poster={startPlay ? "" : poster} poster={startPlay ? "" : poster}
onTimeUpdate={updateProgress} onTimeUpdate={updateProgress}
@ -638,17 +716,43 @@ useEffect(() => {
onPause={onPause} onPause={onPause}
onVolumeChange={onVolumeChangeHandler} onVolumeChange={onVolumeChangeHandler}
controls={false} controls={false}
/> />
{/* <canvas ref={canvasRef} style={{ display: "none" }}></canvas> */} {/* <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}
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} /> <SubtitleManager
subtitleBtnRef={subtitleBtnRef}
close={closeSubtitleManager}
open={isOpenSubtitleManage}
qortalMetadata={qortalVideoResource}
onSelect={onSelectSubtitle}
currentSubTrack={currentSubTrack}
/>
</VideoContainer> </VideoContainer>
</> </>
); );