applied subtitles vtt and srt

This commit is contained in:
PhilReact 2025-06-15 07:00:27 +03:00
parent b48b47ae1c
commit d3c4a4713e
15 changed files with 669 additions and 193 deletions

10
package-lock.json generated
View File

@ -18,6 +18,7 @@
"dexie": "^4.0.11", "dexie": "^4.0.11",
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
"iso-639-1": "^3.1.5",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-hot-toast": "^2.5.2", "react-hot-toast": "^2.5.2",
"react-idle-timer": "^5.7.2", "react-idle-timer": "^5.7.2",
@ -2566,6 +2567,15 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true "dev": true
}, },
"node_modules/iso-639-1": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/iso-639-1/-/iso-639-1-3.1.5.tgz",
"integrity": "sha512-gXkz5+KN7HrG0Q5UGqSMO2qB9AsbEeyLP54kF1YrMsIxmu+g4BdB7rflReZTSTZGpfj8wywu6pfPBCylPIzGQA==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/jackspeak": { "node_modules/jackspeak": {
"version": "3.4.3", "version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",

View File

@ -32,6 +32,7 @@
"dexie": "^4.0.11", "dexie": "^4.0.11",
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
"iso-639-1": "^3.1.5",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-hot-toast": "^2.5.2", "react-hot-toast": "^2.5.2",
"react-idle-timer": "^5.7.2", "react-idle-timer": "^5.7.2",

View File

@ -0,0 +1,33 @@
// components/LanguageSelector.tsx
import React from 'react';
import { Autocomplete, TextField } from '@mui/material';
import { languageOptions } from './SubtitleManager';
export default function LanguageSelector({
value,
onChange,
}: {
value: string | null;
onChange: (value: string | null) => void;
}) {
return (
<Autocomplete
options={languageOptions}
getOptionLabel={(option) => `${option.name} (${option.code})`}
value={languageOptions.find((opt) => opt.code === value) || null}
onChange={(event, newValue) => onChange(newValue?.code || null)}
renderInput={(params) => <TextField {...params} label="Subtitle Language" />}
isOptionEqualToValue={(option, val) => option.code === val.code}
sx={{ width: 300 }}
slotProps={{
popper: {
sx: {
zIndex: 999991, // Must be higher than Dialog's default zIndex (1300)
},
},
}}
/>
);
}

View File

@ -1,117 +1,350 @@
import { useCallback, useEffect, useState } from "react" import React, { useCallback, useEffect, useState } from "react";
import { QortalGetMetadata } from "../../types/interfaces/resources" import { QortalGetMetadata, QortalMetadata, Service } from "../../types/interfaces/resources";
import { Box, ButtonBase, Dialog, DialogContent, DialogTitle, IconButton, Typography } from "@mui/material" import {
alpha,
Box,
Button,
ButtonBase,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
Fade,
IconButton,
Popover,
Typography,
} from "@mui/material";
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 { useResources } from "../../hooks/useResources"; import { Resource, useResources } from "../../hooks/useResources";
import { useGlobal } from "../../context/GlobalProvider"; import { useGlobal } from "../../context/GlobalProvider";
import { ENTITY_SUBTITLE, SERVICE_SUBTITLE } from "./video-player-constants";
import ISO6391, { LanguageCode } from "iso-639-1";
import LanguageSelect from "./LanguageSelect";
import {
useDropzone,
DropzoneRootProps,
DropzoneInputProps,
} from "react-dropzone";
import { fileToBase64, objectToBase64 } from "../../utils/base64";
import { ResourceToPublish } from "../../types/qortalRequests/types";
import { useListReturn } from "../../hooks/useListData";
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;
subtitleBtnRef: any
}
export interface Subtitle {
language: string | null;
base64: string;
type: string;
filename: string;
size: number;
}
export interface SubtitlePublishedData {
language: string | null;
subtitleData: string;
type: string;
filename: string;
size: number;
} }
export const SubtitleManager = ({qortalMetadata, open, close}: SubtitleManagerProps) => {
const [mode, setMode] = useState(1)
const {lists} = useGlobal()
const {fetchResources} = useResources()
// const [subtitles, setSubtitles] = useState([])
const subtitles = useListStore(
(state) => state.lists[`${qortalMetadata?.service}- ${qortalMetadata?.name}-${qortalMetadata?.identifier}`]?.items || []
);
const getPublishedSubtitles = useCallback(async ()=> {
try {
await fetchResources(qortalMetadata, `${qortalMetadata?.service}- ${qortalMetadata?.name}-${qortalMetadata?.identifier}`, "BASE64");
} catch (error) { export const languageOptions = ISO6391.getAllCodes().map((code) => ({
console.error(error) code,
} name: ISO6391.getName(code),
}, []) nativeName: ISO6391.getNativeName(code),
}));
const SubtitleManagerComponent = ({
qortalMetadata,
open,
close,
onSelect,
subtitleBtnRef
}: SubtitleManagerProps) => {
const [mode, setMode] = useState(1);
const { lists, identifierOperations, auth } = useGlobal();
const { fetchResources } = useResources();
// const [subtitles, setSubtitles] = useState([])
const subtitles = useListReturn(`subs-${qortalMetadata?.service}-${qortalMetadata?.name}-${qortalMetadata?.identifier}`)
useEffect(()=> {
if(!qortalMetadata?.identifier || !qortalMetadata?.name || !qortalMetadata?.service) return
// getPublishedSubtitles() console.log('subtitles222', subtitles)
}, [qortalMetadata?.identifier, qortalMetadata?.service, qortalMetadata?.name, getPublishedSubtitles]) const getPublishedSubtitles = useCallback(async () => {
try {
const videoId = `${qortalMetadata?.service}-${qortalMetadata?.name}-${qortalMetadata?.identifier}`;
console.log('videoId', videoId)
const postIdSearch = await identifierOperations.buildSearchPrefix(
ENTITY_SUBTITLE,
videoId,
);
const searchParams = {
service: SERVICE_SUBTITLE,
identifier: postIdSearch,
limit: 0
};
const res = await lists.fetchResources(searchParams, `subs-${videoId}`, "BASE64");
lists.addList(`subs-${videoId}`, res || []);
console.log('resres2', res)
} catch (error) {
console.error(error);
}
}, []);
const handleClose = () => { useEffect(() => {
close() if (
!qortalMetadata?.identifier ||
!qortalMetadata?.name ||
!qortalMetadata?.service
)
return;
getPublishedSubtitles()
}, [
qortalMetadata?.identifier,
qortalMetadata?.service,
qortalMetadata?.name,
getPublishedSubtitles,
]);
const handleClose = () => {
close();
setMode(1); setMode(1);
// setTitle(""); // setTitle("");
// setDescription(""); // setDescription("");
// setHasMetadata(false); // setHasMetadata(false);
}; };
const onSelect = ()=> {
const publishHandler = async (subtitles: Subtitle[]) => {
try {
const videoId = `${qortalMetadata?.service}-${qortalMetadata?.name}-${qortalMetadata?.identifier}`;
const identifier = await identifierOperations.buildIdentifier(ENTITY_SUBTITLE, videoId);
const name = auth?.name
console.log('identifier2', identifier)
if(!name) return
const resources: ResourceToPublish[] = []
const tempResources: {qortalMetadata: QortalMetadata, data: any}[] = []
for(const sub of subtitles ){
const data = {
subtitleData: sub.base64,
language: sub.language,
filename: sub.filename,
type: sub.type
}
const base64Data = await objectToBase64(data)
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({
action: 'PUBLISH_MULTIPLE_QDN_RESOURCES',
resources
})
lists.addNewResources(`subs-${qortalMetadata?.service}-${qortalMetadata?.name}-${qortalMetadata?.identifier}`, tempResources)
} catch (error) {
}
};
const onBack = ()=> {
if(mode === 1) close()
}
const onSelectHandler = (sub: SubtitlePublishedData)=> {
onSelect(sub)
close()
} }
return ( return (
<Dialog <Popover
open={!!open} open={!!open}
fullWidth={true} anchorEl={subtitleBtnRef.current}
maxWidth={"md"} onClose={handleClose}
sx={{ slots={{
zIndex: 999990, transition: Fade,
}} }}
slotProps={{ slotProps={{
paper: { transition: {
elevation: 0, timeout: 200,
}, },
}} paper: {
> sx: {
<DialogTitle>Subtitles</DialogTitle> bgcolor: alpha('#181818', 0.98),
<IconButton color: 'white',
aria-label="close" opacity: 0.9,
onClick={handleClose} borderRadius: 2,
sx={(theme) => ({ boxShadow: 5,
position: "absolute", p: 1,
right: 8, minWidth: 200,
top: 8, },
})} },
}}
anchorOrigin={{
vertical: 'top',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
> >
<CloseIcon /> <Box sx={{
</IconButton> padding: '5px 0px 10px 0px',
{mode === 1 && ( display: 'flex',
gap:'10px',
width: '100%'
}}>
<ButtonBase onClick={onBack}>
<ArrowBackIosIcon sx={{
fontSize: '1.15em'
}}/>
</ButtonBase>
<ButtonBase>
<Typography onClick={onBack} sx={{
fontSize: '0.85rem'
}}>Subtitles</Typography>
</ButtonBase>
<ButtonBase sx={{
marginLeft: 'auto',
}}>
<ModeEditIcon sx={{
fontSize: '1.15rem'
}} />
</ButtonBase>
</Box>
<Divider />
{mode === 1 && (
<PublisherSubtitles <PublisherSubtitles
subtitles={subtitles} subtitles={subtitles}
publisherName={qortalMetadata.name} publisherName={qortalMetadata.name}
setMode={setMode} setMode={setMode}
onSelect={onSelect} onSelect={onSelectHandler}
/> onBack={onBack}
)}
{/* {mode === 2 && (
<CommunitySubtitles
link={open?.link}
name={open?.name}
mode={mode}
setMode={setMode}
username={username}
category={open?.category}
rootName={open?.rootName}
/> />
)} )}
{/* <Box>
{[
'Ambient mode',
'Annotations',
'Subtitles/CC',
'Sleep timer',
'Playback speed',
'Quality',
].map((label) => (
<Typography
key={label}
sx={{
px: 2,
py: 1,
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
cursor: 'pointer',
},
}}
>
{label}
</Typography>
))}
</Box> */}
</Popover>
// <Dialog
// open={!!open}
// fullWidth={true}
// maxWidth={"md"}
// sx={{
// zIndex: 999990,
// }}
// slotProps={{
// paper: {
// elevation: 0,
// },
// }}
// >
// <DialogTitle>Subtitles</DialogTitle>
// <IconButton
// aria-label="close"
// onClick={handleClose}
// sx={(theme) => ({
// position: "absolute",
// right: 8,
// top: 8,
// })}
// >
// <CloseIcon />
// </IconButton>
// <Button onClick={() => setMode(5)}>New subtitles</Button>
// {mode === 1 && (
// <PublisherSubtitles
// subtitles={subtitles}
// publisherName={qortalMetadata.name}
// setMode={setMode}
// onSelect={onSelect}
// />
// )}
// {mode === 5 && <PublishSubtitles publishHandler={publishHandler} />}
// {/* {mode === 2 && (
// <CommunitySubtitles
// link={open?.link}
// name={open?.name}
// mode={mode}
// setMode={setMode}
// username={username}
// category={open?.category}
// rootName={open?.rootName}
// />
// )}
{mode === 4 && ( // {mode === 4 && (
<MySubtitles // <MySubtitles
link={open?.link} // link={open?.link}
name={open?.name} // name={open?.name}
mode={mode} // mode={mode}
setMode={setMode} // setMode={setMode}
username={username} // username={username}
title={title} // title={title}
description={description} // description={description}
setDescription={setDescription} // setDescription={setDescription}
setTitle={setTitle} // setTitle={setTitle}
/> // />
)} */} // )} */}
</Dialog> // </Dialog>
) );
} };
interface PublisherSubtitlesProps { interface PublisherSubtitlesProps {
publisherName: string publisherName: string;
subtitles: any[] subtitles: any[];
setMode: (val: number)=> void setMode: (val: number) => void;
onSelect: (subtitle: any)=> void onSelect: (subtitle: any) => void;
onBack: ()=> void;
} }
const PublisherSubtitles = ({ const PublisherSubtitles = ({
@ -119,8 +352,80 @@ const PublisherSubtitles = ({
subtitles, subtitles,
setMode, setMode,
onSelect, onSelect,
onBack
}: PublisherSubtitlesProps) => { }: PublisherSubtitlesProps) => {
return (
<>
{subtitles?.map((sub)=> {
return <Subtitle onSelect={onSelect} sub={sub} key={`${sub?.qortalMetadata?.service}-${sub?.qortalMetadata?.name}-${sub?.qortalMetadata?.identifier}`}/>
})}
</>
);
};
interface PublishSubtitlesProps {
publishHandler: (subs: Subtitle[])=> void
}
const PublishSubtitles = ({ publishHandler }: PublishSubtitlesProps) => {
const [language, setLanguage] = useState<null | string>(null);
const [subtitles, setSubtitles] = useState<Subtitle[]>([]);
const onDrop = useCallback(async (acceptedFiles: File[]) => {
const newSubtitles: Subtitle[] = [];
for (const file of acceptedFiles) {
try {
const newSubtitle = {
base64: await fileToBase64(file),
language: null,
type: file.type,
filename: file.name,
size: file.size,
};
newSubtitles.push(newSubtitle)
} catch (error) {
console.error("Failed to parse audio file:", error);
}
}
setSubtitles((prev) => [...newSubtitles, ...prev]);
}, []);
const {
getRootProps,
getInputProps,
}: {
getRootProps: () => DropzoneRootProps;
getInputProps: () => DropzoneInputProps;
isDragActive: boolean;
} = useDropzone({
onDrop,
accept: {
"application/x-subrip": [".srt"], // SRT subtitles
"text/vtt": [".vtt"], // WebVTT subtitles
},
multiple: true,
maxSize: 2 * 1024 * 1024, // 2MB
});
const onChangeValue = (field: string, data: any, index: number) => {
const sub = subtitles[index];
if (!sub) return;
const copySub = { ...sub, [field]: data };
setSubtitles((prev) => {
const copyPrev = [...prev];
copyPrev[index] = copySub;
return copyPrev;
});
};
console.log('subtitles', subtitles)
return ( return (
<> <>
<DialogContent> <DialogContent>
@ -133,27 +438,64 @@ const PublisherSubtitles = ({
alignItems: "flex-start", alignItems: "flex-start",
}} }}
> >
<ButtonBase <Box {...getRootProps()}>
<Button
sx={{ sx={{
width: "100%", display: 'flex',
gap: '10px',
}} }}
onClick={() => setMode(2)} variant="contained"
> >
<Box <input {...getInputProps()} />
sx={{ Import subtitles
p: 2, </Button>
border: "2px solid", </Box>
borderRadius: 2, {subtitles?.map((sub, i) => {
width: "100%", return (
}} <>
> <LanguageSelect
<Typography>Create new index</Typography> value={sub.language}
</Box> onChange={(val: string | null) => onChangeValue('language',val, i)}
</ButtonBase> />
</>
);
})}
</Box> </Box>
</DialogContent> </DialogContent>
<DialogActions>
<Button
onClick={()=> publishHandler(subtitles)}
// disabled={disableButton}
variant="contained"
>
Publish index
</Button>
</DialogActions>
</> </>
); );
}; };
interface SubProps {
sub: QortalGetMetadata
onSelect: (subtitle: Subtitle)=> void;
}
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>
}
export const SubtitleManager = React.memo(SubtitleManagerComponent);

View File

@ -35,9 +35,10 @@ interface VideoControlsBarProps {
decreaseSpeed: ()=> void decreaseSpeed: ()=> void
playbackRate: number playbackRate: number
openSubtitleManager: ()=> void openSubtitleManager: ()=> void
subtitleBtnRef: any
} }
export const VideoControlsBar = ({showControls, playbackRate, increaseSpeed,decreaseSpeed, isFullScreen, showControlsFullScreen, reloadVideo, onVolumeChange, volume, isPlaying, canPlay, isScreenSmall, controlsHeight, playerRef, duration, progress, togglePlay, toggleFullscreen, extractFrames, openSubtitleManager}: VideoControlsBarProps) => { export const VideoControlsBar = ({subtitleBtnRef, showControls, playbackRate, increaseSpeed,decreaseSpeed, isFullScreen, showControlsFullScreen, reloadVideo, onVolumeChange, volume, isPlaying, canPlay, isScreenSmall, controlsHeight, playerRef, duration, progress, togglePlay, toggleFullscreen, extractFrames, openSubtitleManager}: VideoControlsBarProps) => {
const showMobileControls = isScreenSmall && canPlay; const showMobileControls = isScreenSmall && canPlay;
@ -96,7 +97,7 @@ export const VideoControlsBar = ({showControls, playbackRate, increaseSpeed,decr
<Box sx={controlGroupSX}> <Box sx={controlGroupSX}>
<PlaybackRate playbackRate={playbackRate} increaseSpeed={increaseSpeed} decreaseSpeed={decreaseSpeed} /> <PlaybackRate playbackRate={playbackRate} increaseSpeed={increaseSpeed} decreaseSpeed={decreaseSpeed} />
<ObjectFitButton /> <ObjectFitButton />
<IconButton onClick={openSubtitleManager}> <IconButton ref={subtitleBtnRef} onClick={openSubtitleManager}>
sub sub
</IconButton> </IconButton>
<PictureInPictureButton /> <PictureInPictureButton />

View File

@ -10,9 +10,30 @@ 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 { SubtitleManager } from "./SubtitleManager"; 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"; type StretchVideoType = "contain" | "fill" | "cover" | "none" | "scale-down";
@ -142,6 +163,7 @@ export const VideoPlayer = ({
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 { const {
reloadVideo, reloadVideo,
togglePlay, togglePlay,
@ -219,12 +241,10 @@ const closeSubtitleManager = useCallback(()=> {
useVideoPlayerHotKeys(hotkeyHandlers); useVideoPlayerHotKeys(hotkeyHandlers);
const updateProgress = useCallback(() => { const updateProgress = useCallback(() => {
console.log('currentTime2')
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();
console.log('currentTime3', 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);
@ -251,7 +271,6 @@ const closeSubtitleManager = useCallback(()=> {
(e: React.SyntheticEvent<HTMLVideoElement, Event>) => { (e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
try { try {
const video = e.currentTarget; const video = e.currentTarget;
console.log('onVolumeChangeHandler')
setVolume(video.volume); setVolume(video.volume);
setIsMuted(video.muted); setIsMuted(video.muted);
} catch (error) { } catch (error) {
@ -271,7 +290,6 @@ const closeSubtitleManager = useCallback(()=> {
}; };
}, [showControls]); }, [showControls]);
console.log('isFullscreen', isFullscreen, showControlsFullScreen)
const videoStylesVideo = useMemo(() => { const videoStylesVideo = useMemo(() => {
return { return {
@ -318,11 +336,9 @@ const closeSubtitleManager = useCallback(()=> {
const enterFullscreen = () => { const enterFullscreen = () => {
const ref = containerRef?.current as any; const ref = containerRef?.current as any;
console.log('refffff', ref)
if (!ref) return; if (!ref) return;
if (ref.requestFullscreen && !isFullscreen) { if (ref.requestFullscreen && !isFullscreen) {
console.log('requset ')
ref.requestFullscreen(); ref.requestFullscreen();
} }
@ -399,7 +415,102 @@ useEffect(() => {
}; };
}, []); }, []);
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(() => { const handleMouseLeave = useCallback(() => {
setShowControls(false); setShowControls(false);
@ -413,7 +524,6 @@ useEffect(() => {
useEffect(() => { useEffect(() => {
if (!resourceUrl || !isReady || !videoLocactionStringified || !startPlay) return; if (!resourceUrl || !isReady || !videoLocactionStringified || !startPlay) return;
console.log("EFFECT TRIGGERED", { isReady, resourceUrl, startPlay, poster, videoLocactionStringified });
const resource = JSON.parse(videoLocactionStringified) const resource = JSON.parse(videoLocactionStringified)
let canceled = false; let canceled = false;
@ -437,7 +547,6 @@ useEffect(() => {
}, },
], ],
}; };
console.log('options', options)
const ref = videoRef as any; const ref = videoRef as any;
if (!ref.current) return; if (!ref.current) return;
@ -448,13 +557,7 @@ useEffect(() => {
playerRef.current?.playbackRate(playbackRate) playerRef.current?.playbackRate(playbackRate)
playerRef.current?.volume(volume); playerRef.current?.volume(volume);
playerRef.current?.addRemoteTextTrack({
kind: 'subtitles',
src: 'http://127.0.0.1:22393/arbitrary/DOCUMENT/a-test/test-identifier',
srclang: 'en',
label: 'English',
default: true
}, true);
playerRef.current?.play(); playerRef.current?.play();
}); });
@ -472,7 +575,6 @@ useEffect(() => {
console.error('useEffect start player', error) console.error('useEffect start player', error)
} }
return () => { return () => {
console.log('canceled')
canceled = true; canceled = true;
const player = playerRef.current; const player = playerRef.current;
@ -490,12 +592,10 @@ useEffect(() => {
useEffect(() => { useEffect(() => {
if(!isPlayerInitialized) return if(!isPlayerInitialized) return
const player = playerRef?.current; const player = playerRef?.current;
console.log('player rate', player)
if (!player) return; if (!player) return;
const handleRateChange = () => { const handleRateChange = () => {
const newRate = player?.playbackRate(); const newRate = player?.playbackRate();
console.log('Playback rate changed:', newRate);
if(newRate){ if(newRate){
setPlaybackRate(newRate); // or any other state/action setPlaybackRate(newRate); // or any other state/action
} }
@ -544,11 +644,11 @@ useEffect(() => {
{isReady && ( {isReady && (
<VideoControlsBar playbackRate={playbackRate} increaseSpeed={hotkeyHandlers.increaseSpeed} <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} /> 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 close={closeSubtitleManager} open={isOpenSubtitleManage} qortalMetadata={qortalVideoResource} /> <SubtitleManager subtitleBtnRef={subtitleBtnRef} close={closeSubtitleManager} open={isOpenSubtitleManage} qortalMetadata={qortalVideoResource} onSelect={onSelectSubtitle} />
</VideoContainer> </VideoContainer>
</> </>
); );

View File

@ -62,13 +62,11 @@ export const useVideoPlayerController = (props: UseVideoControls) => {
useEffect(() => { useEffect(() => {
if (videoLocation && isPlayerInitialized) { if (videoLocation && isPlayerInitialized) {
console.log('hellohhhh5')
try { try {
const ref = playerRef as any; const ref = playerRef as any;
if (!ref.current) return; if (!ref.current) return;
const savedProgress = getProgress(videoLocation); const savedProgress = getProgress(videoLocation);
console.log('savedProgress', savedProgress)
if (typeof savedProgress === "number") { if (typeof savedProgress === "number") {
playerRef.current?.currentTime(savedProgress); playerRef.current?.currentTime(savedProgress);

View File

@ -0,0 +1,4 @@
import { Service } from "../../types/interfaces/resources";
export const ENTITY_SUBTITLE = "ENTITY_SUBTITLE";
export const SERVICE_SUBTITLE: Service = "FILE"

View File

@ -0,0 +1,9 @@
import React, { useMemo } from "react";
import { useListStore } from "../state/lists";
import { useCacheStore } from "../state/cache"; // Assuming you export getResourceCache
import { QortalGetMetadata } from "../types/interfaces/resources";
export function useListReturn(listName: string): QortalGetMetadata[] {
const list = useListStore((state) => state.lists[listName]?.items) || [];
return list
}

View File

@ -28,6 +28,7 @@ export const useResources = (retryAttempts: number = 2) => {
const addTemporaryResource = useCacheStore((s) => s.addTemporaryResource); const addTemporaryResource = useCacheStore((s) => s.addTemporaryResource);
const markResourceAsDeleted = useCacheStore((s) => s.markResourceAsDeleted); const markResourceAsDeleted = useCacheStore((s) => s.markResourceAsDeleted);
const setSearchParamsForList = useCacheStore((s) => s.setSearchParamsForList); const setSearchParamsForList = useCacheStore((s) => s.setSearchParamsForList);
const addList = useListStore((s) => s.addList);
const deleteList = useListStore(state => state.deleteList) const deleteList = useListStore(state => state.deleteList)
const requestControllers = new Map<string, AbortController>(); const requestControllers = new Map<string, AbortController>();
@ -204,9 +205,10 @@ export const useResources = (retryAttempts: number = 2) => {
if (cancelRequests) { if (cancelRequests) {
cancelAllRequests(); cancelAllRequests();
} }
console.log('listName', listName)
const cacheKey = generateCacheKey(params); const cacheKey = generateCacheKey(params);
const searchCache = getSearchCache(listName, cacheKey); const searchCache = getSearchCache(listName, cacheKey);
console.log('searchCache', searchCache)
if (searchCache) { if (searchCache) {
const copyParams = {...params} const copyParams = {...params}
delete copyParams.after delete copyParams.after
@ -219,9 +221,12 @@ export const useResources = (retryAttempts: number = 2) => {
let responseData: QortalMetadata[] = []; let responseData: QortalMetadata[] = [];
let filteredResults: QortalMetadata[] = []; let filteredResults: QortalMetadata[] = [];
let lastCreated = params.before || undefined; let lastCreated = params.before || undefined;
console.log('lastCreated', lastCreated)
const targetLimit = params.limit ?? 20; // Use `params.limit` if provided, else default to 20 const targetLimit = params.limit ?? 20; // Use `params.limit` if provided, else default to 20
const isUnlimited = params.limit === 0;
while (filteredResults.length < targetLimit) { while (isUnlimited || filteredResults.length < targetLimit) {
console.log('beforebefore')
const response = await qortalRequest({ const response = await qortalRequest({
action: "SEARCH_QDN_RESOURCES", action: "SEARCH_QDN_RESOURCES",
mode: "ALL", mode: "ALL",
@ -229,27 +234,31 @@ export const useResources = (retryAttempts: number = 2) => {
limit: targetLimit - filteredResults.length, // Adjust limit dynamically limit: targetLimit - filteredResults.length, // Adjust limit dynamically
before: lastCreated, before: lastCreated,
}); });
console.log('responseresponse', response)
if (!response || response.length === 0) { if (!response || response.length === 0) {
break; // No more data available break; // No more data available
} }
responseData = response; responseData = response;
const validResults = responseData.filter((item) => item.size !== 32); const validResults = responseData.filter((item) => item.size !== 32);
console.log('validResults', validResults)
filteredResults = [...filteredResults, ...validResults]; filteredResults = [...filteredResults, ...validResults];
if (filteredResults.length >= targetLimit) { if (filteredResults.length >= targetLimit && !isUnlimited) {
filteredResults = filteredResults.slice(0, targetLimit); filteredResults = filteredResults.slice(0, targetLimit);
break; break;
} }
lastCreated = responseData[responseData.length - 1]?.created; lastCreated = responseData[responseData.length - 1]?.created;
if (isUnlimited) break;
if (!lastCreated) break; if (!lastCreated) break;
} }
const copyParams = {...params} const copyParams = {...params}
delete copyParams.after delete copyParams.after
delete copyParams.before delete copyParams.before
delete copyParams.offset delete copyParams.offset
console.log('listName2', listName, filteredResults)
setSearchCache(listName, cacheKey, filteredResults, cancelRequests ? JSON.stringify(copyParams) : null); setSearchCache(listName, cacheKey, filteredResults, cancelRequests ? JSON.stringify(copyParams) : null);
fetchDataFromResults(filteredResults, returnType); fetchDataFromResults(filteredResults, returnType);
@ -350,15 +359,15 @@ export const useResources = (retryAttempts: number = 2) => {
}, []); }, []);
return useMemo(() => ({ return useMemo(() => ({
fetchResources, fetchResources,
addNewResources, addNewResources,
updateNewResources, updateNewResources,
deleteResource, deleteResource,
deleteList, deleteList,
addList,
fetchResourcesResultsOnly fetchResourcesResultsOnly
}), [fetchResources, addNewResources, updateNewResources, deleteResource, deleteList, fetchResourcesResultsOnly]); }), [fetchResources, addNewResources, updateNewResources, deleteResource, deleteList, fetchResourcesResultsOnly, addList]);
}; };

View File

@ -6,6 +6,7 @@ export { useModal } from './hooks/useModal';
export { AudioPlayerControls , OnTrackChangeMeta, AudioPlayerProps, AudioPlayerHandle} from './components/AudioPlayer/AudioPlayerControls'; export { AudioPlayerControls , OnTrackChangeMeta, AudioPlayerProps, AudioPlayerHandle} from './components/AudioPlayer/AudioPlayerControls';
export { useAudioPlayerHotkeys } from './components/AudioPlayer/useAudioPlayerHotkeys'; export { useAudioPlayerHotkeys } from './components/AudioPlayer/useAudioPlayerHotkeys';
export { VideoPlayer } from './components/VideoPlayer/VideoPlayer'; export { VideoPlayer } from './components/VideoPlayer/VideoPlayer';
export { useListReturn } from './hooks/useListData';
import './index.css' import './index.css'
export { executeEvent, subscribeToEvent, unsubscribeFromEvent } from './utils/events'; export { executeEvent, subscribeToEvent, unsubscribeFromEvent } from './utils/events';
export { formatBytes, formatDuration } from './utils/numbers'; export { formatBytes, formatDuration } from './utils/numbers';

View File

@ -51,7 +51,7 @@ export type Service =
| "VOICE_PRIVATE" | "VOICE_PRIVATE"
| "DOCUMENT_PRIVATE" | "DOCUMENT_PRIVATE"
| "MAIL_PRIVATE" | "MAIL_PRIVATE"
| "MESSAGE_PRIVATE"; | "MESSAGE_PRIVATE" | 'AUTO_UPDATE';
export interface QortalMetadata { export interface QortalMetadata {

View File

@ -1,3 +1,4 @@
import { Service } from "../interfaces/resources";
import { import {
Coin, Coin,
ConfirmationStatus, ConfirmationStatus,
@ -8,7 +9,6 @@ import {
ForeignCoin, ForeignCoin,
ResourcePointer, ResourcePointer,
ResourceToPublish, ResourceToPublish,
Service,
TxType, TxType,
} from "./types"; } from "./types";

View File

@ -1,3 +1,5 @@
import { Service } from "../interfaces/resources"
export type ForeignCoin = export type ForeignCoin =
| 'BTC' | 'BTC'
| 'LTC' | 'LTC'
@ -31,61 +33,7 @@ export type ForeignCoin =
qortalAtAddress: string; qortalAtAddress: string;
} }
export type Service =
| 'AUTO_UPDATE'
| 'ARBITRARY_DATA'
| 'QCHAT_ATTACHMENT'
| 'QCHAT_ATTACHMENT_PRIVATE'
| 'ATTACHMENT'
| 'ATTACHMENT_PRIVATE'
| 'FILE'
| 'FILE_PRIVATE'
| 'FILES'
| 'CHAIN_DATA'
| 'WEBSITE'
| 'GIT_REPOSITORY'
| 'IMAGE'
| 'IMAGE_PRIVATE'
| 'THUMBNAIL'
| 'QCHAT_IMAGE'
| 'VIDEO'
| 'VIDEO_PRIVATE'
| 'AUDIO'
| 'AUDIO_PRIVATE'
| 'QCHAT_AUDIO'
| 'QCHAT_VOICE'
| 'VOICE'
| 'VOICE_PRIVATE'
| 'PODCAST'
| 'BLOG'
| 'BLOG_POST'
| 'BLOG_COMMENT'
| 'DOCUMENT'
| 'DOCUMENT_PRIVATE'
| 'LIST'
| 'PLAYLIST'
| 'APP'
| 'METADATA'
| 'JSON'
| 'GIF_REPOSITORY'
| 'STORE'
| 'PRODUCT'
| 'OFFER'
| 'COUPON'
| 'CODE'
| 'PLUGIN'
| 'EXTENSION'
| 'GAME'
| 'ITEM'
| 'NFT'
| 'DATABASE'
| 'SNAPSHOT'
| 'COMMENT'
| 'CHAIN_COMMENT'
| 'MAIL'
| 'MAIL_PRIVATE'
| 'MESSAGE'
| 'MESSAGE_PRIVATE'
export type ResourceToPublish = export type ResourceToPublish =

View File

@ -119,3 +119,23 @@ export function base64ToObject(base64: string){
return toObject return toObject
} }
export const base64ToBlobUrl = (base64: string, mimeType = 'text/vtt'): string => {
console.log('base64ToBlobUrl', base64, mimeType)
const cleanedBase64 = base64.length % 4 === 0 ? base64 : base64 + '='.repeat(4 - base64.length % 4);
try {
const binary = atob(cleanedBase64);
const len = binary.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mimeType });
return URL.createObjectURL(blob);
} catch (err) {
console.error("Failed to decode base64:", err);
return '';
}
};