mirror of
https://github.com/Qortal/qapp-core.git
synced 2025-06-15 18:01:21 +00:00
applied subtitles vtt and srt
This commit is contained in:
parent
b48b47ae1c
commit
d3c4a4713e
10
package-lock.json
generated
10
package-lock.json
generated
@ -18,6 +18,7 @@
|
||||
"dexie": "^4.0.11",
|
||||
"dompurify": "^3.2.4",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"iso-639-1": "^3.1.5",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-idle-timer": "^5.7.2",
|
||||
@ -2566,6 +2567,15 @@
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"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": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||
|
@ -32,6 +32,7 @@
|
||||
"dexie": "^4.0.11",
|
||||
"dompurify": "^3.2.4",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"iso-639-1": "^3.1.5",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-idle-timer": "^5.7.2",
|
||||
|
33
src/components/VideoPlayer/LanguageSelect.tsx
Normal file
33
src/components/VideoPlayer/LanguageSelect.tsx
Normal 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)
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
/>
|
||||
);
|
||||
}
|
@ -1,117 +1,350 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { QortalGetMetadata } from "../../types/interfaces/resources"
|
||||
import { Box, ButtonBase, Dialog, DialogContent, DialogTitle, IconButton, Typography } from "@mui/material"
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { QortalGetMetadata, QortalMetadata, Service } from "../../types/interfaces/resources";
|
||||
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 { useListStore } from "../../state/lists";
|
||||
import { useResources } from "../../hooks/useResources";
|
||||
import { Resource, useResources } from "../../hooks/useResources";
|
||||
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 {
|
||||
qortalMetadata: QortalGetMetadata
|
||||
close: ()=> void
|
||||
open: boolean
|
||||
qortalMetadata: QortalGetMetadata;
|
||||
close: () => void;
|
||||
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) {
|
||||
console.error(error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(()=> {
|
||||
if(!qortalMetadata?.identifier || !qortalMetadata?.name || !qortalMetadata?.service) return
|
||||
export const languageOptions = ISO6391.getAllCodes().map((code) => ({
|
||||
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}`)
|
||||
|
||||
|
||||
// getPublishedSubtitles()
|
||||
}, [qortalMetadata?.identifier, qortalMetadata?.service, qortalMetadata?.name, getPublishedSubtitles])
|
||||
console.log('subtitles222', subtitles)
|
||||
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 = () => {
|
||||
close()
|
||||
useEffect(() => {
|
||||
if (
|
||||
!qortalMetadata?.identifier ||
|
||||
!qortalMetadata?.name ||
|
||||
!qortalMetadata?.service
|
||||
)
|
||||
return;
|
||||
|
||||
getPublishedSubtitles()
|
||||
}, [
|
||||
qortalMetadata?.identifier,
|
||||
qortalMetadata?.service,
|
||||
qortalMetadata?.name,
|
||||
getPublishedSubtitles,
|
||||
]);
|
||||
|
||||
const handleClose = () => {
|
||||
close();
|
||||
setMode(1);
|
||||
// setTitle("");
|
||||
// setDescription("");
|
||||
// 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 (
|
||||
<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,
|
||||
})}
|
||||
<Popover
|
||||
open={!!open}
|
||||
anchorEl={subtitleBtnRef.current}
|
||||
onClose={handleClose}
|
||||
slots={{
|
||||
transition: Fade,
|
||||
}}
|
||||
slotProps={{
|
||||
transition: {
|
||||
timeout: 200,
|
||||
},
|
||||
paper: {
|
||||
sx: {
|
||||
bgcolor: alpha('#181818', 0.98),
|
||||
color: 'white',
|
||||
opacity: 0.9,
|
||||
borderRadius: 2,
|
||||
boxShadow: 5,
|
||||
p: 1,
|
||||
minWidth: 200,
|
||||
},
|
||||
},
|
||||
}}
|
||||
anchorOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
{mode === 1 && (
|
||||
<Box sx={{
|
||||
padding: '5px 0px 10px 0px',
|
||||
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
|
||||
subtitles={subtitles}
|
||||
publisherName={qortalMetadata.name}
|
||||
setMode={setMode}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
)}
|
||||
{/* {mode === 2 && (
|
||||
<CommunitySubtitles
|
||||
link={open?.link}
|
||||
name={open?.name}
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
username={username}
|
||||
category={open?.category}
|
||||
rootName={open?.rootName}
|
||||
onSelect={onSelectHandler}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
{/* <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 && (
|
||||
<MySubtitles
|
||||
link={open?.link}
|
||||
name={open?.name}
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
username={username}
|
||||
title={title}
|
||||
description={description}
|
||||
setDescription={setDescription}
|
||||
setTitle={setTitle}
|
||||
/>
|
||||
)} */}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
// {mode === 4 && (
|
||||
// <MySubtitles
|
||||
// link={open?.link}
|
||||
// name={open?.name}
|
||||
// mode={mode}
|
||||
// setMode={setMode}
|
||||
// username={username}
|
||||
// title={title}
|
||||
// description={description}
|
||||
// setDescription={setDescription}
|
||||
// setTitle={setTitle}
|
||||
// />
|
||||
// )} */}
|
||||
// </Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
interface PublisherSubtitlesProps {
|
||||
publisherName: string
|
||||
subtitles: any[]
|
||||
setMode: (val: number)=> void
|
||||
onSelect: (subtitle: any)=> void
|
||||
publisherName: string;
|
||||
subtitles: any[];
|
||||
setMode: (val: number) => void;
|
||||
onSelect: (subtitle: any) => void;
|
||||
onBack: ()=> void;
|
||||
}
|
||||
|
||||
const PublisherSubtitles = ({
|
||||
@ -119,8 +352,80 @@ const PublisherSubtitles = ({
|
||||
subtitles,
|
||||
setMode,
|
||||
onSelect,
|
||||
onBack
|
||||
}: 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 (
|
||||
<>
|
||||
<DialogContent>
|
||||
@ -133,27 +438,64 @@ const PublisherSubtitles = ({
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
<ButtonBase
|
||||
<Box {...getRootProps()}>
|
||||
<Button
|
||||
sx={{
|
||||
width: "100%",
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
}}
|
||||
onClick={() => setMode(2)}
|
||||
variant="contained"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
border: "2px solid",
|
||||
borderRadius: 2,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Typography>Create new index</Typography>
|
||||
</Box>
|
||||
</ButtonBase>
|
||||
|
||||
|
||||
<input {...getInputProps()} />
|
||||
Import subtitles
|
||||
</Button>
|
||||
</Box>
|
||||
{subtitles?.map((sub, i) => {
|
||||
return (
|
||||
<>
|
||||
<LanguageSelect
|
||||
value={sub.language}
|
||||
onChange={(val: string | null) => onChangeValue('language',val, i)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</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);
|
||||
|
@ -35,9 +35,10 @@ interface VideoControlsBarProps {
|
||||
decreaseSpeed: ()=> void
|
||||
playbackRate: number
|
||||
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;
|
||||
|
||||
@ -96,7 +97,7 @@ export const VideoControlsBar = ({showControls, playbackRate, increaseSpeed,decr
|
||||
<Box sx={controlGroupSX}>
|
||||
<PlaybackRate playbackRate={playbackRate} increaseSpeed={increaseSpeed} decreaseSpeed={decreaseSpeed} />
|
||||
<ObjectFitButton />
|
||||
<IconButton onClick={openSubtitleManager}>
|
||||
<IconButton ref={subtitleBtnRef} onClick={openSubtitleManager}>
|
||||
sub
|
||||
</IconButton>
|
||||
<PictureInPictureButton />
|
||||
|
@ -10,9 +10,30 @@ import videojs from 'video.js';
|
||||
import 'video.js/dist/video-js.css';
|
||||
|
||||
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";
|
||||
|
||||
|
||||
@ -142,6 +163,7 @@ export const VideoPlayer = ({
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showControls, setShowControls] = useState(false)
|
||||
const [isOpenSubtitleManage, setIsOpenSubtitleManage] = useState(false)
|
||||
const subtitleBtnRef = useRef(null)
|
||||
const {
|
||||
reloadVideo,
|
||||
togglePlay,
|
||||
@ -219,12 +241,10 @@ const closeSubtitleManager = useCallback(()=> {
|
||||
useVideoPlayerHotKeys(hotkeyHandlers);
|
||||
|
||||
const updateProgress = useCallback(() => {
|
||||
console.log('currentTime2')
|
||||
const player = playerRef?.current;
|
||||
if (!player || typeof player?.currentTime !== 'function') return;
|
||||
|
||||
const currentTime = player.currentTime();
|
||||
console.log('currentTime3', currentTime)
|
||||
if (typeof currentTime === 'number' && videoLocation && currentTime > 0.1) {
|
||||
setProgress(videoLocation, currentTime);
|
||||
setLocalProgress(currentTime);
|
||||
@ -251,7 +271,6 @@ const closeSubtitleManager = useCallback(()=> {
|
||||
(e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
|
||||
try {
|
||||
const video = e.currentTarget;
|
||||
console.log('onVolumeChangeHandler')
|
||||
setVolume(video.volume);
|
||||
setIsMuted(video.muted);
|
||||
} catch (error) {
|
||||
@ -271,7 +290,6 @@ const closeSubtitleManager = useCallback(()=> {
|
||||
};
|
||||
}, [showControls]);
|
||||
|
||||
console.log('isFullscreen', isFullscreen, showControlsFullScreen)
|
||||
|
||||
const videoStylesVideo = useMemo(() => {
|
||||
return {
|
||||
@ -318,11 +336,9 @@ const closeSubtitleManager = useCallback(()=> {
|
||||
|
||||
const enterFullscreen = () => {
|
||||
const ref = containerRef?.current as any;
|
||||
console.log('refffff', ref)
|
||||
if (!ref) return;
|
||||
|
||||
if (ref.requestFullscreen && !isFullscreen) {
|
||||
console.log('requset ')
|
||||
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(() => {
|
||||
setShowControls(false);
|
||||
@ -413,7 +524,6 @@ useEffect(() => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!resourceUrl || !isReady || !videoLocactionStringified || !startPlay) return;
|
||||
console.log("EFFECT TRIGGERED", { isReady, resourceUrl, startPlay, poster, videoLocactionStringified });
|
||||
|
||||
const resource = JSON.parse(videoLocactionStringified)
|
||||
let canceled = false;
|
||||
@ -437,7 +547,6 @@ useEffect(() => {
|
||||
},
|
||||
],
|
||||
};
|
||||
console.log('options', options)
|
||||
const ref = videoRef as any;
|
||||
if (!ref.current) return;
|
||||
|
||||
@ -448,13 +557,7 @@ useEffect(() => {
|
||||
playerRef.current?.playbackRate(playbackRate)
|
||||
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();
|
||||
|
||||
});
|
||||
@ -472,7 +575,6 @@ useEffect(() => {
|
||||
console.error('useEffect start player', error)
|
||||
}
|
||||
return () => {
|
||||
console.log('canceled')
|
||||
canceled = true;
|
||||
const player = playerRef.current;
|
||||
|
||||
@ -490,12 +592,10 @@ useEffect(() => {
|
||||
useEffect(() => {
|
||||
if(!isPlayerInitialized) return
|
||||
const player = playerRef?.current;
|
||||
console.log('player rate', player)
|
||||
if (!player) return;
|
||||
|
||||
const handleRateChange = () => {
|
||||
const newRate = player?.playbackRate();
|
||||
console.log('Playback rate changed:', newRate);
|
||||
if(newRate){
|
||||
setPlaybackRate(newRate); // or any other state/action
|
||||
}
|
||||
@ -544,11 +644,11 @@ useEffect(() => {
|
||||
|
||||
|
||||
{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} />
|
||||
)}
|
||||
|
||||
<SubtitleManager close={closeSubtitleManager} open={isOpenSubtitleManage} qortalMetadata={qortalVideoResource} />
|
||||
<SubtitleManager subtitleBtnRef={subtitleBtnRef} close={closeSubtitleManager} open={isOpenSubtitleManage} qortalMetadata={qortalVideoResource} onSelect={onSelectSubtitle} />
|
||||
</VideoContainer>
|
||||
</>
|
||||
);
|
||||
|
@ -62,13 +62,11 @@ export const useVideoPlayerController = (props: UseVideoControls) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (videoLocation && isPlayerInitialized) {
|
||||
console.log('hellohhhh5')
|
||||
try {
|
||||
const ref = playerRef as any;
|
||||
if (!ref.current) return;
|
||||
|
||||
const savedProgress = getProgress(videoLocation);
|
||||
console.log('savedProgress', savedProgress)
|
||||
if (typeof savedProgress === "number") {
|
||||
playerRef.current?.currentTime(savedProgress);
|
||||
|
||||
|
4
src/components/VideoPlayer/video-player-constants.ts
Normal file
4
src/components/VideoPlayer/video-player-constants.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { Service } from "../../types/interfaces/resources";
|
||||
|
||||
export const ENTITY_SUBTITLE = "ENTITY_SUBTITLE";
|
||||
export const SERVICE_SUBTITLE: Service = "FILE"
|
9
src/hooks/useListData.tsx
Normal file
9
src/hooks/useListData.tsx
Normal 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
|
||||
}
|
@ -28,6 +28,7 @@ export const useResources = (retryAttempts: number = 2) => {
|
||||
const addTemporaryResource = useCacheStore((s) => s.addTemporaryResource);
|
||||
const markResourceAsDeleted = useCacheStore((s) => s.markResourceAsDeleted);
|
||||
const setSearchParamsForList = useCacheStore((s) => s.setSearchParamsForList);
|
||||
const addList = useListStore((s) => s.addList);
|
||||
|
||||
const deleteList = useListStore(state => state.deleteList)
|
||||
const requestControllers = new Map<string, AbortController>();
|
||||
@ -204,9 +205,10 @@ export const useResources = (retryAttempts: number = 2) => {
|
||||
if (cancelRequests) {
|
||||
cancelAllRequests();
|
||||
}
|
||||
|
||||
console.log('listName', listName)
|
||||
const cacheKey = generateCacheKey(params);
|
||||
const searchCache = getSearchCache(listName, cacheKey);
|
||||
console.log('searchCache', searchCache)
|
||||
if (searchCache) {
|
||||
const copyParams = {...params}
|
||||
delete copyParams.after
|
||||
@ -219,9 +221,12 @@ export const useResources = (retryAttempts: number = 2) => {
|
||||
let responseData: QortalMetadata[] = [];
|
||||
let filteredResults: QortalMetadata[] = [];
|
||||
let lastCreated = params.before || undefined;
|
||||
console.log('lastCreated', lastCreated)
|
||||
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({
|
||||
action: "SEARCH_QDN_RESOURCES",
|
||||
mode: "ALL",
|
||||
@ -229,27 +234,31 @@ export const useResources = (retryAttempts: number = 2) => {
|
||||
limit: targetLimit - filteredResults.length, // Adjust limit dynamically
|
||||
before: lastCreated,
|
||||
});
|
||||
|
||||
console.log('responseresponse', response)
|
||||
if (!response || response.length === 0) {
|
||||
break; // No more data available
|
||||
}
|
||||
|
||||
responseData = response;
|
||||
const validResults = responseData.filter((item) => item.size !== 32);
|
||||
console.log('validResults', validResults)
|
||||
filteredResults = [...filteredResults, ...validResults];
|
||||
|
||||
if (filteredResults.length >= targetLimit) {
|
||||
if (filteredResults.length >= targetLimit && !isUnlimited) {
|
||||
filteredResults = filteredResults.slice(0, targetLimit);
|
||||
break;
|
||||
}
|
||||
|
||||
lastCreated = responseData[responseData.length - 1]?.created;
|
||||
if (isUnlimited) break;
|
||||
|
||||
if (!lastCreated) break;
|
||||
}
|
||||
const copyParams = {...params}
|
||||
delete copyParams.after
|
||||
delete copyParams.before
|
||||
delete copyParams.offset
|
||||
console.log('listName2', listName, filteredResults)
|
||||
setSearchCache(listName, cacheKey, filteredResults, cancelRequests ? JSON.stringify(copyParams) : null);
|
||||
fetchDataFromResults(filteredResults, returnType);
|
||||
|
||||
@ -349,7 +358,6 @@ export const useResources = (retryAttempts: number = 2) => {
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
return useMemo(() => ({
|
||||
fetchResources,
|
||||
@ -357,8 +365,9 @@ export const useResources = (retryAttempts: number = 2) => {
|
||||
updateNewResources,
|
||||
deleteResource,
|
||||
deleteList,
|
||||
addList,
|
||||
fetchResourcesResultsOnly
|
||||
}), [fetchResources, addNewResources, updateNewResources, deleteResource, deleteList, fetchResourcesResultsOnly]);
|
||||
}), [fetchResources, addNewResources, updateNewResources, deleteResource, deleteList, fetchResourcesResultsOnly, addList]);
|
||||
|
||||
};
|
||||
|
||||
|
@ -6,6 +6,7 @@ export { useModal } from './hooks/useModal';
|
||||
export { AudioPlayerControls , OnTrackChangeMeta, AudioPlayerProps, AudioPlayerHandle} from './components/AudioPlayer/AudioPlayerControls';
|
||||
export { useAudioPlayerHotkeys } from './components/AudioPlayer/useAudioPlayerHotkeys';
|
||||
export { VideoPlayer } from './components/VideoPlayer/VideoPlayer';
|
||||
export { useListReturn } from './hooks/useListData';
|
||||
import './index.css'
|
||||
export { executeEvent, subscribeToEvent, unsubscribeFromEvent } from './utils/events';
|
||||
export { formatBytes, formatDuration } from './utils/numbers';
|
||||
|
@ -51,7 +51,7 @@ export type Service =
|
||||
| "VOICE_PRIVATE"
|
||||
| "DOCUMENT_PRIVATE"
|
||||
| "MAIL_PRIVATE"
|
||||
| "MESSAGE_PRIVATE";
|
||||
| "MESSAGE_PRIVATE" | 'AUTO_UPDATE';
|
||||
|
||||
|
||||
export interface QortalMetadata {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { Service } from "../interfaces/resources";
|
||||
import {
|
||||
Coin,
|
||||
ConfirmationStatus,
|
||||
@ -8,7 +9,6 @@ import {
|
||||
ForeignCoin,
|
||||
ResourcePointer,
|
||||
ResourceToPublish,
|
||||
Service,
|
||||
TxType,
|
||||
} from "./types";
|
||||
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { Service } from "../interfaces/resources"
|
||||
|
||||
export type ForeignCoin =
|
||||
| 'BTC'
|
||||
| 'LTC'
|
||||
@ -31,61 +33,7 @@ export type ForeignCoin =
|
||||
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 =
|
||||
|
@ -118,4 +118,24 @@ export function base64ToObject(base64: string){
|
||||
const toObject = uint8ArrayToObject(toUint);
|
||||
|
||||
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 '';
|
||||
}
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user