mirror of
https://github.com/Qortal/qapp-core.git
synced 2025-11-02 21:17:04 +00:00
Merge pull request #2 from Qortal/feature/video-player
Feature/video player
This commit is contained in:
1917
package-lock.json
generated
1917
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "qapp-core",
|
||||
"version": "1.0.31",
|
||||
"version": "1.0.51",
|
||||
"description": "Qortal's core React library with global state, UI components, and utilities",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.mjs",
|
||||
@@ -18,23 +18,32 @@
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"generate-i18n": "./scripts/generate_locales.js",
|
||||
"build": "npm run generate-i18n && tsup",
|
||||
"prepare": "npm run build",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.2",
|
||||
"bloom-filters": "^3.0.4",
|
||||
"buffer": "^6.0.3",
|
||||
"compressorjs": "^1.2.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dexie": "^4.0.11",
|
||||
"dompurify": "^3.2.4",
|
||||
"i18next": "^25.3.2",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"iso-639-1": "^3.1.5",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-i18next": "^15.6.1",
|
||||
"react-idle-timer": "^5.7.2",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
"react-rnd": "^10.5.2",
|
||||
"short-unique-id": "^5.2.0",
|
||||
"srt-webvtt": "^2.0.0",
|
||||
"ts-key-enum": "^3.0.13",
|
||||
"video.js": "^8.23.3",
|
||||
"zustand": "^4.3.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -42,7 +51,9 @@
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/icons-material": "^7.0.1",
|
||||
"@mui/material": "^7.0.1",
|
||||
"react": "^19.0.0"
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
@@ -53,6 +64,8 @@
|
||||
"@types/react": "^19.0.10",
|
||||
"cpy-cli": "^5.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.6.2",
|
||||
"tsup": "^8.4.0",
|
||||
"typescript": "^5.2.0"
|
||||
},
|
||||
|
||||
36
scripts/generate_locales.js
Executable file
36
scripts/generate_locales.js
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fg = require('fast-glob');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const LOCALES_DIR = path.resolve(__dirname, '../src/i18n/locales');
|
||||
const OUTPUT_FILE = path.join(__dirname, '../src/i18n/compiled-i18n.json');
|
||||
|
||||
(async () => {
|
||||
const files = await fg('**/*.json', { cwd: LOCALES_DIR, absolute: true });
|
||||
|
||||
const resources = {};
|
||||
const supportedLanguages = new Set();
|
||||
|
||||
for (const filePath of files) {
|
||||
const parts = filePath.split(path.sep);
|
||||
const lang = parts[parts.length - 2];
|
||||
const ns = path.basename(filePath, '.json');
|
||||
|
||||
supportedLanguages.add(lang);
|
||||
|
||||
const json = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
|
||||
if (!resources[lang]) resources[lang] = {};
|
||||
resources[lang][ns] = json;
|
||||
}
|
||||
|
||||
// Save compiled resources and languages
|
||||
fs.writeFileSync(
|
||||
OUTPUT_FILE,
|
||||
JSON.stringify({ resources, supportedLanguages: Array.from(supportedLanguages) }, null, 2)
|
||||
);
|
||||
|
||||
console.log('✅ i18n resources generated.');
|
||||
})();
|
||||
14
src/common/useIdleTimeout.ts
Normal file
14
src/common/useIdleTimeout.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { useIdleTimer } from "react-idle-timer";
|
||||
|
||||
const useIdleTimeout = ({ onIdle, onActive, idleTime = 10_000 }: any) => {
|
||||
const idleTimer = useIdleTimer({
|
||||
timeout: idleTime,
|
||||
onIdle: onIdle,
|
||||
onActive: onActive,
|
||||
});
|
||||
return {
|
||||
idleTimer,
|
||||
};
|
||||
};
|
||||
export default useIdleTimeout;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export const useScrollTrackerRef = (listName: string, hasList: boolean, scrollerRef: React.RefObject<HTMLElement | null> | undefined) => {
|
||||
export const useScrollTrackerRef = (listName: string, hasList: boolean, scrollerRef: React.RefObject<HTMLElement | null> | undefined | null) => {
|
||||
const [hasMounted, setHasMounted] = useState(false)
|
||||
const hasScrollRef = useRef(false)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -39,6 +39,8 @@ import { useModal } from "../useModal";
|
||||
import { createAvatarLink } from "../../utils/qortal";
|
||||
import { extractComponents } from "../../utils/text";
|
||||
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
|
||||
import { useLibTranslation } from "../../hooks/useLibTranslation";
|
||||
import { t } from "i18next";
|
||||
|
||||
const uid = new ShortUniqueId({ length: 10, dictionary: "alphanum" });
|
||||
|
||||
@@ -57,6 +59,8 @@ interface PropsIndexManager {
|
||||
const cleanString = (str: string) => str.replace(/\s{2,}/g, ' ').trim().toLocaleLowerCase();
|
||||
|
||||
export const IndexManager = ({ username }: PropsIndexManager) => {
|
||||
const { t } = useLibTranslation();
|
||||
|
||||
const open = useIndexStore((state) => state.open);
|
||||
const setOpen = useIndexStore((state) => state.setOpen);
|
||||
const [title, setTitle] = useState("");
|
||||
@@ -123,7 +127,7 @@ export const IndexManager = ({ username }: PropsIndexManager) => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Index manager</DialogTitle>
|
||||
<DialogTitle>{t("index.title")}</DialogTitle>
|
||||
<IconButton
|
||||
aria-label="close"
|
||||
onClick={handleClose}
|
||||
@@ -196,6 +200,8 @@ const EntryMode = ({
|
||||
username,
|
||||
hasMetadata,
|
||||
}: PropsEntryMode) => {
|
||||
const { t } = useLibTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogContent>
|
||||
@@ -222,7 +228,7 @@ const EntryMode = ({
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Typography>Create new index</Typography>
|
||||
<Typography>{t("index.create_new_index")}</Typography>
|
||||
</Box>
|
||||
</ButtonBase>
|
||||
{/* <ButtonBase
|
||||
@@ -260,7 +266,7 @@ const EntryMode = ({
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Typography>Add metadata</Typography>
|
||||
<Typography>{t("index.add_metadata")}</Typography>
|
||||
{hasMetadata && <CheckCircleIcon color="success" />}
|
||||
</Box>
|
||||
</ButtonBase>
|
||||
@@ -288,12 +294,14 @@ const AddMetadata = ({
|
||||
setDescription,
|
||||
setTitle,
|
||||
}: PropsAddMetadata) => {
|
||||
const { t } = useLibTranslation();
|
||||
|
||||
const publish = usePublish();
|
||||
|
||||
const disableButton = !title.trim() || !description.trim() || !name || !link;
|
||||
|
||||
const createMetadata = async () => {
|
||||
const loadId = showLoading("Publishing metadata...");
|
||||
const loadId = showLoading(t("index.publishing_metadata"));
|
||||
try {
|
||||
const identifierWithoutHash = name + link;
|
||||
const identifier = await hashWordWithoutPublicSalt(
|
||||
@@ -313,7 +321,7 @@ const AddMetadata = ({
|
||||
});
|
||||
|
||||
if (res?.signature) {
|
||||
showSuccess("Successfully published metadata");
|
||||
showSuccess(t("index.published_metadata"));
|
||||
publish.updatePublish(
|
||||
{
|
||||
identifier,
|
||||
@@ -325,7 +333,7 @@ const AddMetadata = ({
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to publish metadata";
|
||||
error instanceof Error ? error.message : t("index.failed_metadata");
|
||||
showError(message);
|
||||
} finally {
|
||||
dismissToast(loadId);
|
||||
@@ -350,7 +358,7 @@ const AddMetadata = ({
|
||||
<IconButton disabled={mode === 1} onClick={() => setMode(1)}>
|
||||
<ArrowBackIosIcon />
|
||||
</IconButton>
|
||||
<Typography>Example of how it could look like:</Typography>
|
||||
<Typography>{t("index.example")}</Typography>
|
||||
<Card sx={{
|
||||
width: '100%',
|
||||
padding: '5px'
|
||||
@@ -437,12 +445,12 @@ const AddMetadata = ({
|
||||
</Box>
|
||||
</Card>
|
||||
<Box>
|
||||
<Typography>Title</Typography>
|
||||
<Typography>{t("index.metadata.title")}</Typography>
|
||||
<TextField
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
size="small"
|
||||
placeholder="Add a title for the link"
|
||||
placeholder={t("index.metadata_title_placeholder")}
|
||||
slotProps={{
|
||||
htmlInput: { maxLength: 50 },
|
||||
}}
|
||||
@@ -452,19 +460,19 @@ const AddMetadata = ({
|
||||
variant="caption"
|
||||
color={title.length >= 50 ? "error" : "text.secondary"}
|
||||
>
|
||||
{title.length}/{50} characters
|
||||
{title.length}/{50} {` ${t("index.characters")}`}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography>Description</Typography>
|
||||
<Typography>{t("index.metadata.description")}</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
size="small"
|
||||
placeholder="Add a description for the link"
|
||||
placeholder={t("index.metadata_description_placeholder")}
|
||||
slotProps={{
|
||||
htmlInput: { maxLength: 120 },
|
||||
}}
|
||||
@@ -473,7 +481,7 @@ const AddMetadata = ({
|
||||
variant="caption"
|
||||
color={description.length >= 120 ? "error" : "text.secondary"}
|
||||
>
|
||||
{description.length}/{120} characters
|
||||
{description.length}/{120} {` ${t("index.characters")}`}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
@@ -486,7 +494,7 @@ const AddMetadata = ({
|
||||
disabled={disableButton}
|
||||
variant="contained"
|
||||
>
|
||||
Publish metadata
|
||||
{t("actions.publish_metadata")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
@@ -507,6 +515,8 @@ const CreateIndex = ({
|
||||
category,
|
||||
rootName,
|
||||
}: PropsCreateIndex) => {
|
||||
const { t } = useLibTranslation();
|
||||
|
||||
const [terms, setTerms] = useState<string[]>([]);
|
||||
const publish = usePublish();
|
||||
const [size, setSize] = useState(0);
|
||||
@@ -588,7 +598,7 @@ const CreateIndex = ({
|
||||
const disableButton = (terms.length === 0 && !recommendedSelection) || !name || !link;
|
||||
|
||||
const createIndex = async () => {
|
||||
const loadId = showLoading("Publishing index...");
|
||||
const loadId = showLoading(t("index.publishing_index"));
|
||||
try {
|
||||
const hashedRootName = await hashWordWithoutPublicSalt(rootName, 20);
|
||||
const hashedLink = await hashWordWithoutPublicSalt(link, 20);
|
||||
@@ -603,7 +613,7 @@ const CreateIndex = ({
|
||||
});
|
||||
|
||||
if (res?.signature) {
|
||||
showSuccess("Successfully published index");
|
||||
showSuccess(t("index.published_index"));
|
||||
publish.updatePublish(
|
||||
{
|
||||
identifier,
|
||||
@@ -616,7 +626,7 @@ const CreateIndex = ({
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to publish index";
|
||||
error instanceof Error ? error.message : t("index.failed_index");
|
||||
showError(message);
|
||||
} finally {
|
||||
dismissToast(loadId);
|
||||
@@ -645,7 +655,7 @@ const CreateIndex = ({
|
||||
</IconButton>
|
||||
{recommendedIndices?.length > 0 && (
|
||||
<>
|
||||
<Typography>Recommended Indices</Typography>
|
||||
<Typography>{t("index.recommended_indices")}</Typography>
|
||||
<RadioGroup
|
||||
aria-labelledby="demo-controlled-radio-buttons-group"
|
||||
name="controlled-radio-buttons-group"
|
||||
@@ -673,7 +683,7 @@ const CreateIndex = ({
|
||||
value={recommendedSelection}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<FormControlLabel value="" control={<Radio />} label="Add search term" />
|
||||
<FormControlLabel value="" control={<Radio />} label={t("index.add_search_term")} />
|
||||
</RadioGroup>
|
||||
<Spacer height="10px" />
|
||||
{!recommendedSelection && (
|
||||
@@ -681,7 +691,7 @@ const CreateIndex = ({
|
||||
maxLength={17}
|
||||
items={terms}
|
||||
onlyStrings
|
||||
label="search terms"
|
||||
label={t("index.search_terms")}
|
||||
setItems={async (termsNew: string[]) => {
|
||||
try {
|
||||
if (terms?.length === 1 && termsNew?.length === 2) {
|
||||
@@ -704,8 +714,10 @@ const CreateIndex = ({
|
||||
shouldRecommendMax && fullSize > 230 ? "visible" : "hidden",
|
||||
}}
|
||||
>
|
||||
It is recommended to keep your term character count below{" "}
|
||||
{recommendedSize} characters
|
||||
|
||||
{t("index.recommendation_size", {
|
||||
recommendedSize
|
||||
})}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -716,7 +728,7 @@ const CreateIndex = ({
|
||||
disabled={disableButton}
|
||||
variant="contained"
|
||||
>
|
||||
Publish index
|
||||
{t("actions.publish_index")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
<Dialog
|
||||
@@ -733,18 +745,17 @@ const CreateIndex = ({
|
||||
}}
|
||||
>
|
||||
<DialogTitle id="alert-dialog-title">
|
||||
Adding multiple indices
|
||||
{t("index.multiple_title")}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="alert-dialog-description">
|
||||
Subsequent indices will keep your publish fees lower, but they will
|
||||
have less strength in future search results.
|
||||
{t("index.multiple_description")}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button variant="contained" onClick={onCancel}>Cancel</Button>
|
||||
<Button variant="contained" onClick={onCancel}>{t("actions.cancel")}</Button>
|
||||
<Button variant="contained" onClick={onOk}>
|
||||
Continue
|
||||
{t("actions.continue")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
@@ -762,6 +773,8 @@ const YourIndices = ({
|
||||
category,
|
||||
rootName,
|
||||
}: PropsCreateIndex) => {
|
||||
const { t } = useLibTranslation();
|
||||
|
||||
const [terms, setTerms] = useState<string[]>([]);
|
||||
const publish = usePublish();
|
||||
const [size, setSize] = useState(0);
|
||||
|
||||
354
src/components/MultiPublish/MultiPublishDialog.tsx
Normal file
354
src/components/MultiPublish/MultiPublishDialog.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
Typography,
|
||||
Box,
|
||||
LinearProgress,
|
||||
Stack,
|
||||
DialogActions,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import { PublishStatus, useMultiplePublishStore, usePublishStatusStore } from "../../state/multiplePublish";
|
||||
import { ResourceToPublish } from "../../types/qortalRequests/types";
|
||||
import { QortalGetMetadata } from "../../types/interfaces/resources";
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import { useLibTranslation } from "../../hooks/useLibTranslation";
|
||||
import { t } from "i18next";
|
||||
|
||||
export interface MultiplePublishError {
|
||||
error: {
|
||||
unsuccessfulPublishes: any[]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const MultiPublishDialogComponent = () => {
|
||||
const { t } = useLibTranslation();
|
||||
|
||||
const {
|
||||
resources,
|
||||
isPublishing,
|
||||
failedResources,
|
||||
reset,
|
||||
reject,
|
||||
error: publishError,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
setError,
|
||||
setFailedPublishResources,
|
||||
complete
|
||||
} = useMultiplePublishStore((state) => ({
|
||||
resources: state.resources,
|
||||
isPublishing: state.isPublishing,
|
||||
failedResources: state.failedResources,
|
||||
reset: state.reset,
|
||||
reject: state.reject,
|
||||
error: state.error,
|
||||
isLoading: state.isLoading,
|
||||
setIsLoading: state.setIsLoading,
|
||||
setError: state.setError,
|
||||
setFailedPublishResources: state.setFailedPublishResources,
|
||||
complete: state.complete
|
||||
}));
|
||||
|
||||
const { publishStatus, setPublishStatusByKey, reset: resetStatusStore } = usePublishStatusStore();
|
||||
|
||||
const resourcesToPublish = useMemo(() => {
|
||||
return resources.filter((item) =>
|
||||
failedResources.some((f) =>
|
||||
f?.name === item?.name && f?.identifier === item?.identifier && f?.service === item?.service
|
||||
)
|
||||
);
|
||||
}, [resources, failedResources]);
|
||||
|
||||
const publishMultipleResources = useCallback(async () => {
|
||||
const timeout = resources.length * 1200000;
|
||||
|
||||
try {
|
||||
resourcesToPublish.forEach((item) => {
|
||||
const key = `${item?.service}-${item?.name}-${item?.identifier}`;
|
||||
setPublishStatusByKey(key, {
|
||||
error: undefined,
|
||||
chunks: 0,
|
||||
totalChunks: 0
|
||||
});
|
||||
});
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setFailedPublishResources([]);
|
||||
|
||||
const result = await qortalRequestWithTimeout(
|
||||
{ action: 'PUBLISH_MULTIPLE_QDN_RESOURCES', resources: resourcesToPublish },
|
||||
timeout
|
||||
);
|
||||
|
||||
complete(result);
|
||||
reset();
|
||||
resetStatusStore()
|
||||
} catch (error: any) {
|
||||
const unPublished = error?.error?.unsuccessfulPublishes;
|
||||
const failedPublishes: QortalGetMetadata[] = [];
|
||||
|
||||
if (unPublished && Array.isArray(unPublished)) {
|
||||
unPublished.forEach((item) => {
|
||||
const key = `${item?.service}-${item?.name}-${item?.identifier}`;
|
||||
setPublishStatusByKey(key, {
|
||||
error: { reason: item?.reason }
|
||||
});
|
||||
|
||||
failedPublishes.push({
|
||||
name: item?.name,
|
||||
service: item?.service,
|
||||
identifier: item?.identifier
|
||||
});
|
||||
});
|
||||
setFailedPublishResources(failedPublishes);
|
||||
} else {
|
||||
setError(error instanceof Error ? error.message : 'Error during publish');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [resourcesToPublish, resources, setPublishStatusByKey, setIsLoading, setError, setFailedPublishResources, complete]);
|
||||
|
||||
const handleNavigation = useCallback((event: any) => {
|
||||
if (event.data?.action !== 'PUBLISH_STATUS') return;
|
||||
|
||||
const data = event.data;
|
||||
|
||||
if (
|
||||
!data.publishLocation ||
|
||||
typeof data.publishLocation?.name !== 'string' ||
|
||||
typeof data.publishLocation?.service !== 'string'
|
||||
) {
|
||||
console.warn('Invalid PUBLISH_STATUS data, skipping:', data);
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
publishLocation,
|
||||
chunks,
|
||||
totalChunks,
|
||||
retry,
|
||||
filename,
|
||||
processed
|
||||
} = data;
|
||||
|
||||
const key = `${publishLocation?.service}-${publishLocation?.name}-${publishLocation?.identifier}`;
|
||||
|
||||
const update: any = {
|
||||
publishLocation,
|
||||
processed: processed || false
|
||||
};
|
||||
if (chunks != null && chunks !== undefined) update.chunks = chunks;
|
||||
if (totalChunks != null && totalChunks !== undefined) update.totalChunks = totalChunks;
|
||||
if (retry != null && retry !== undefined) update.retry = retry;
|
||||
if (filename != null && retry !== undefined) update.filename = filename;
|
||||
|
||||
try {
|
||||
setPublishStatusByKey(key, update);
|
||||
} catch (err) {
|
||||
console.error('Failed to set publish status:', err);
|
||||
}
|
||||
}, [setPublishStatusByKey]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("message", handleNavigation);
|
||||
return () => window.removeEventListener("message", handleNavigation);
|
||||
}, [handleNavigation]);
|
||||
|
||||
if (!isPublishing) return null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isPublishing}
|
||||
fullWidth
|
||||
maxWidth="sm"
|
||||
sx={{ zIndex: 999990 }}
|
||||
slotProps={{ paper: { elevation: 0 } }}
|
||||
disableEnforceFocus
|
||||
disableAutoFocus
|
||||
disableRestoreFocus
|
||||
>
|
||||
<DialogTitle>{t("multi_publish.title")}</DialogTitle>
|
||||
<DialogContent>
|
||||
{publishError && (
|
||||
<Stack spacing={3}>
|
||||
<Box mt={2} sx={{ display: 'flex', gap: '5px', alignItems: 'center' }}>
|
||||
<ErrorIcon color="error" />
|
||||
<Typography variant="body2">{publishError}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{!publishError && (
|
||||
<Stack spacing={3}>
|
||||
{resources.map((publish) => {
|
||||
const key = `${publish?.service}-${publish?.name}-${publish?.identifier}`;
|
||||
const individualPublishStatus = publishStatus[key] || null;
|
||||
return (
|
||||
<IndividualResourceComponent
|
||||
key={key}
|
||||
publishKey={key}
|
||||
publish={publish}
|
||||
publishStatus={individualPublishStatus}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{
|
||||
flexDirection: 'column',
|
||||
gap: '15px'
|
||||
}}>
|
||||
{failedResources?.length > 0 && (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
gap: '10px'
|
||||
}}>
|
||||
<ErrorIcon color="error" />
|
||||
<Typography variant="body2">{t("multi_publish.publish_failed")}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
width: '100%',
|
||||
justifyContent: 'flex-end'
|
||||
}}>
|
||||
<Button disabled={isLoading} color="error" variant="contained" onClick={() => {
|
||||
reject(new Error('Canceled Publish'));
|
||||
reset();
|
||||
}}>
|
||||
{t("actions.close")}
|
||||
</Button>
|
||||
{failedResources?.length > 0 && (
|
||||
<Button
|
||||
disabled={isLoading || resourcesToPublish.length === 0}
|
||||
color="success"
|
||||
variant="contained"
|
||||
onClick={publishMultipleResources}
|
||||
>
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</DialogActions>
|
||||
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
interface IndividualResourceComponentProps {
|
||||
publish: ResourceToPublish
|
||||
publishStatus: PublishStatus
|
||||
publishKey: string
|
||||
}
|
||||
|
||||
const IndividualResourceComponent = ({ publish, publishKey, publishStatus }: IndividualResourceComponentProps) => {
|
||||
const { t } = useLibTranslation();
|
||||
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const [processingStart, setProcessingStart] = useState<number | undefined>();
|
||||
|
||||
const chunkPercent = useMemo(() => {
|
||||
if (!publishStatus?.chunks || !publishStatus?.totalChunks) return 0;
|
||||
return (publishStatus.chunks / publishStatus.totalChunks) * 100;
|
||||
}, [publishStatus?.chunks, publishStatus?.totalChunks]);
|
||||
|
||||
const chunkDone = useMemo(() => {
|
||||
return (
|
||||
publishStatus?.chunks > 0 &&
|
||||
publishStatus?.totalChunks > 0 &&
|
||||
publishStatus?.chunks === publishStatus?.totalChunks
|
||||
);
|
||||
}, [publishStatus?.chunks, publishStatus?.totalChunks]);
|
||||
|
||||
// Start processing timer once chunking completes
|
||||
useEffect(() => {
|
||||
if (chunkDone && !processingStart) {
|
||||
setProcessingStart(Date.now());
|
||||
}
|
||||
}, [chunkDone, processingStart]);
|
||||
|
||||
// Keep time ticking for progress simulation
|
||||
useEffect(() => {
|
||||
if (!chunkDone) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setNow(Date.now());
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [chunkDone]);
|
||||
const processingPercent = useMemo(() => {
|
||||
if (publishStatus?.error || !chunkDone || !processingStart || !publishStatus?.totalChunks || !now) return 0;
|
||||
|
||||
const totalMB = publishStatus.totalChunks * 5; // assume 5MB per chunk
|
||||
const estimatedProcessingMs = (300_000 / 2048) * totalMB; // 5min per 2GB scaled
|
||||
|
||||
const elapsed = now - processingStart;
|
||||
if (elapsed <= 0) return 0;
|
||||
return Math.min((elapsed / estimatedProcessingMs) * 100, 100);
|
||||
}, [chunkDone, processingStart, now, publishStatus?.totalChunks, publishStatus?.error]);
|
||||
|
||||
return (
|
||||
<Box p={1} border={1} borderColor="divider" borderRadius={2}>
|
||||
<Typography variant="subtitle1" fontWeight="bold">
|
||||
{publish?.filename || publishStatus?.filename || publishKey}
|
||||
</Typography>
|
||||
|
||||
<Box mt={2}>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
{t("multi_publish.file_chunk")} {publishStatus?.chunks || 0}/{publishStatus?.totalChunks || 0} ({chunkPercent.toFixed(0)}%)
|
||||
</Typography>
|
||||
<LinearProgress variant="determinate" value={chunkPercent} />
|
||||
</Box>
|
||||
|
||||
<Box mt={2}>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
{t("multi_publish.file_processing")} ({publishStatus?.processed ? 100 : processingStart ? processingPercent.toFixed(0) : '0'}%)
|
||||
</Typography>
|
||||
<LinearProgress variant="determinate" value={publishStatus?.processed ? 100 : processingPercent} />
|
||||
</Box>
|
||||
|
||||
{publishStatus?.processed && (
|
||||
<Box mt={2} display="flex" gap={1} alignItems="center">
|
||||
<CheckCircleIcon color="success" />
|
||||
<Typography variant="body2">{t("multi_publish.success")}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{publishStatus?.retry && !publishStatus?.error && !publishStatus?.processed && (
|
||||
<Box mt={2} display="flex" gap={1} alignItems="center">
|
||||
<ErrorIcon color="error" />
|
||||
<Typography variant="body2">{t("multi_publish.attempt_retry")}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{publishStatus?.error && !publishStatus?.processed && (
|
||||
<Box mt={2} display="flex" gap={1} alignItems="center">
|
||||
<ErrorIcon color="error" />
|
||||
<Typography variant="body2">
|
||||
{t("multi_publish.publish_failed")} - {publishStatus?.error?.reason || 'Unknown error'}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export const MultiPublishDialog = React.memo(MultiPublishDialogComponent);
|
||||
@@ -52,7 +52,7 @@ const DynamicGrid: React.FC<DynamicGridProps> = ({
|
||||
}}
|
||||
>
|
||||
{items.map((component, index) => (
|
||||
<div ref={index === 0 ? itemContainerRef : null} key={index} style={{ width: "100%", display: "flex", justifyContent: "center", maxWidth: '400px' }}>
|
||||
<div ref={index === 0 ? itemContainerRef : null} key={index} style={{ width: "100%", display: "flex", justifyContent: "center" }}>
|
||||
{component} {/* ✅ Renders user-provided component */}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -18,7 +18,7 @@ interface HorizontalPaginatedListProps {
|
||||
defaultLoaderParams?: DefaultLoaderParams;
|
||||
}
|
||||
|
||||
export const HorizontalPaginatedList = ({
|
||||
const MemorizedComponent = ({
|
||||
items,
|
||||
listItem,
|
||||
loaderItem,
|
||||
@@ -30,6 +30,7 @@ export const HorizontalPaginatedList = ({
|
||||
disablePagination,
|
||||
defaultLoaderParams
|
||||
}: HorizontalPaginatedListProps) => {
|
||||
|
||||
const lastItemRef= useRef<any>(null)
|
||||
const lastItemRef2= useRef<any>(null)
|
||||
const [columnsPerRow, setColumnsPerRow] = useState<null | number>(null)
|
||||
@@ -102,3 +103,7 @@ const displayedItems = disablePagination ? items : items?.length < (displayedLim
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
export const HorizontalPaginatedList = React.memo(MemorizedComponent);
|
||||
|
||||
@@ -83,7 +83,7 @@ interface BaseProps {
|
||||
}
|
||||
onNewData?: (hasNewData: boolean) => void;
|
||||
ref?: any
|
||||
scrollerRef?: React.RefObject<HTMLElement | null>
|
||||
scrollerRef?: React.RefObject<HTMLElement | null> | null
|
||||
}
|
||||
|
||||
const defaultStyles = {
|
||||
@@ -160,7 +160,7 @@ const addItems = useListStore((s) => s.addItems);
|
||||
|
||||
|
||||
|
||||
const searchIntervalRef = useRef<null | number>(null)
|
||||
const searchIntervalRef = useRef<any>(null)
|
||||
const lastItemTimestampRef = useRef<null | number>(null)
|
||||
const stringifiedEntityParams = useMemo(()=> {
|
||||
if(!entityParams) return null
|
||||
@@ -366,6 +366,14 @@ const setResourceCacheExpiryDuration = useCacheStore((s) => s.setResourceCacheEx
|
||||
return listItem(item, index);
|
||||
}, [ listItem]);
|
||||
|
||||
const onLoadLess = useCallback((displayLimit: number)=> {
|
||||
removeFromList(listName, displayLimit)
|
||||
}, [removeFromList])
|
||||
const onLoadMore = useCallback((displayLimit: number)=> {
|
||||
getResourceMoreList(displayLimit)
|
||||
}, [getResourceMoreList])
|
||||
|
||||
|
||||
return (
|
||||
<div ref={elementRef} style={{
|
||||
width: '100%',
|
||||
@@ -417,9 +425,7 @@ const setResourceCacheExpiryDuration = useCacheStore((s) => s.setResourceCacheEx
|
||||
)}
|
||||
{disableVirtualization && direction === "HORIZONTAL" && (
|
||||
<>
|
||||
<HorizontalPaginatedList defaultLoaderParams={defaultLoaderParams} disablePagination={disablePagination} limit={search?.limit || 20} onLoadLess={(displayLimit)=> {
|
||||
removeFromList(listName, displayLimit)
|
||||
}} items={listToDisplay} listItem={renderListItem} onLoadMore={(displayLimit)=> getResourceMoreList(displayLimit)} gap={styles?.gap} minItemWidth={styles?.horizontalStyles?.minItemWidth} loaderItem={loaderItem} />
|
||||
<HorizontalPaginatedList defaultLoaderParams={defaultLoaderParams} disablePagination={disablePagination} limit={search?.limit || 20} onLoadLess={onLoadLess} items={listToDisplay} listItem={renderListItem} onLoadMore={onLoadMore} gap={styles?.gap} minItemWidth={styles?.horizontalStyles?.minItemWidth} loaderItem={loaderItem} />
|
||||
</>
|
||||
|
||||
)}
|
||||
@@ -458,7 +464,6 @@ function arePropsEqual(
|
||||
|
||||
export const ResourceListDisplay = React.memo(MemorizedComponent, arePropsEqual);
|
||||
|
||||
|
||||
interface ListItemWrapperProps {
|
||||
item: QortalMetadata;
|
||||
index: number;
|
||||
@@ -474,13 +479,27 @@ export const ListItemWrapper: React.FC<ListItemWrapperProps> = ({
|
||||
defaultLoaderParams,
|
||||
renderListItemLoader,
|
||||
}) => {
|
||||
const getResourceCache = useCacheStore((s)=> s.getResourceCache)
|
||||
const resourceKey = `${item.service}-${item.name}-${item.identifier}`;
|
||||
|
||||
const findCachedResource = getResourceCache(
|
||||
`${item.service}-${item.name}-${item.identifier}`,
|
||||
true
|
||||
);
|
||||
if (findCachedResource === null && !renderListItemLoader)
|
||||
const entry = useCacheStore((s) => s.resourceCache[resourceKey]);
|
||||
const [validResource, setValidResource] = useState(entry?.data ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!entry) return setValidResource(null);
|
||||
|
||||
if (entry.expiry > Date.now()) {
|
||||
setValidResource(entry.data);
|
||||
} else {
|
||||
useCacheStore.setState((s) => {
|
||||
const newCache = { ...s.resourceCache };
|
||||
delete newCache[resourceKey];
|
||||
return { resourceCache: newCache };
|
||||
});
|
||||
setValidResource(null);
|
||||
}
|
||||
}, [entry, resourceKey]);
|
||||
|
||||
if (validResource === null && !renderListItemLoader)
|
||||
return (
|
||||
<ItemCardWrapper height={60} isInCart={false}>
|
||||
<ResourceLoader
|
||||
@@ -491,7 +510,7 @@ export const ListItemWrapper: React.FC<ListItemWrapperProps> = ({
|
||||
/>
|
||||
</ItemCardWrapper>
|
||||
);
|
||||
if (findCachedResource === false && !renderListItemLoader)
|
||||
if (validResource === false && !renderListItemLoader)
|
||||
return (
|
||||
<ItemCardWrapper height={60} isInCart={false}>
|
||||
<ResourceLoader
|
||||
@@ -505,16 +524,16 @@ export const ListItemWrapper: React.FC<ListItemWrapperProps> = ({
|
||||
);
|
||||
if (
|
||||
renderListItemLoader &&
|
||||
(findCachedResource === false || findCachedResource === null)
|
||||
(validResource === false || validResource === null)
|
||||
) {
|
||||
return renderListItemLoader(
|
||||
findCachedResource === null ? "LOADING" : "ERROR"
|
||||
validResource === null ? "LOADING" : "ERROR"
|
||||
);
|
||||
}
|
||||
|
||||
// Example transformation (Modify item if needed)
|
||||
const transformedItem = findCachedResource
|
||||
? findCachedResource
|
||||
const transformedItem = validResource
|
||||
? validResource
|
||||
: { qortalMetadata: item, data: null };
|
||||
|
||||
return <>{render(transformedItem, index)}</>;
|
||||
|
||||
457
src/components/ResourceList/ResourceListPreloadedDisplay.tsx
Normal file
457
src/components/ResourceList/ResourceListPreloadedDisplay.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
import React, {
|
||||
CSSProperties,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
QortalMetadata,
|
||||
} from "../../types/interfaces/resources";
|
||||
import { VirtualizedList } from "../../common/VirtualizedList";
|
||||
import { ListLoader } from "../../common/ListLoader";
|
||||
import { ListItem, useCacheStore } from "../../state/cache";
|
||||
import { ResourceLoader } from "./ResourceLoader";
|
||||
import { ItemCardWrapper } from "./ItemCardWrapper";
|
||||
import { Spacer } from "../../common/Spacer";
|
||||
import { useListStore } from "../../state/lists";
|
||||
import { useScrollTracker } from "../../common/useScrollTracker";
|
||||
import { HorizontalPaginatedList } from "./HorizontalPaginationList";
|
||||
import { VerticalPaginatedList } from "./VerticalPaginationList";
|
||||
import { useIdentifiers } from "../../hooks/useIdentifiers";
|
||||
import { useGlobal } from "../../context/GlobalProvider";
|
||||
import { useScrollTrackerRef } from "../../common/useScrollTrackerRef";
|
||||
type Direction = "VERTICAL" | "HORIZONTAL";
|
||||
|
||||
interface ResourceListStyles {
|
||||
gap?: number;
|
||||
listLoadingHeight?: CSSProperties;
|
||||
disabledVirutalizationStyles?: {
|
||||
parentContainer?: CSSProperties;
|
||||
};
|
||||
horizontalStyles?: {
|
||||
minItemWidth?: number
|
||||
}
|
||||
}
|
||||
|
||||
interface EntityParams {
|
||||
entityType: string;
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface DefaultLoaderParams {
|
||||
listLoadingText?: string;
|
||||
listNoResultsText?: string;
|
||||
listItemLoadingText?: string;
|
||||
listItemErrorText?: string;
|
||||
}
|
||||
|
||||
export type ReturnType = 'JSON' | 'BASE64'
|
||||
|
||||
export interface Results {
|
||||
resourceItems: QortalMetadata[]
|
||||
isLoadingList: boolean
|
||||
}
|
||||
|
||||
interface BaseProps {
|
||||
listOfResources: QortalMetadata[];
|
||||
listItem: (item: ListItem, index: number) => React.ReactNode;
|
||||
styles?: ResourceListStyles;
|
||||
loaderItem?: (status: "LOADING" | "ERROR") => React.ReactNode;
|
||||
defaultLoaderParams?: DefaultLoaderParams;
|
||||
loaderList?: (status: "LOADING" | "NO_RESULTS") => React.ReactNode;
|
||||
disableVirtualization?: boolean;
|
||||
onSeenLastItem?: (listItem: QortalMetadata) => void;
|
||||
listName: string,
|
||||
children?: React.ReactNode;
|
||||
searchCacheDuration?: number
|
||||
resourceCacheDuration?: number
|
||||
disablePagination?: boolean
|
||||
disableScrollTracker?: boolean
|
||||
retryAttempts?: number
|
||||
returnType: 'JSON' | 'BASE64'
|
||||
onResults?: (results: Results)=> void
|
||||
onNewData?: (hasNewData: boolean) => void;
|
||||
ref?: any
|
||||
scrollerRef?: React.RefObject<HTMLElement | null> | null,
|
||||
limit: number
|
||||
}
|
||||
|
||||
const defaultStyles = {
|
||||
gap: 1
|
||||
}
|
||||
|
||||
// ✅ Restrict `direction` only when `disableVirtualization = false`
|
||||
interface VirtualizedProps extends BaseProps {
|
||||
disableVirtualization?: false;
|
||||
direction?: "VERTICAL"; // Only allow "VERTICAL" when virtualization is enabled
|
||||
}
|
||||
|
||||
interface NonVirtualizedProps extends BaseProps {
|
||||
disableVirtualization: true;
|
||||
direction?: Direction; // Allow both "VERTICAL" & "HORIZONTAL" when virtualization is disabled
|
||||
}
|
||||
|
||||
type PropsResourceListDisplay = VirtualizedProps | NonVirtualizedProps;
|
||||
|
||||
|
||||
export const MemorizedComponent = ({
|
||||
///
|
||||
listOfResources,
|
||||
///
|
||||
listItem,
|
||||
limit,
|
||||
styles = defaultStyles,
|
||||
defaultLoaderParams,
|
||||
loaderItem,
|
||||
loaderList,
|
||||
disableVirtualization,
|
||||
direction = "VERTICAL",
|
||||
onSeenLastItem,
|
||||
listName,
|
||||
searchCacheDuration,
|
||||
resourceCacheDuration,
|
||||
disablePagination,
|
||||
disableScrollTracker,
|
||||
returnType = 'JSON',
|
||||
retryAttempts = 2,
|
||||
onResults,
|
||||
onNewData,
|
||||
ref,
|
||||
scrollerRef
|
||||
}: PropsResourceListDisplay) => {
|
||||
const { lists} = useGlobal()
|
||||
|
||||
|
||||
const temporaryResources = useCacheStore().getTemporaryResources(listName)
|
||||
const list = useListStore((state) => state.lists[listName]?.items) || [];
|
||||
const [isLoading, setIsLoading] = useState(list?.length > 0 ? false : true);
|
||||
|
||||
const isListExpired = useCacheStore().isListExpired(listName)
|
||||
const isListExpiredRef = useRef<boolean | string>(true)
|
||||
useEffect(()=> {
|
||||
isListExpiredRef.current = isListExpired
|
||||
}, [isListExpired])
|
||||
|
||||
const filterOutDeletedResources = useCacheStore((s) => s.filterOutDeletedResources);
|
||||
const deletedResources = useCacheStore((s) => s.deletedResources);
|
||||
|
||||
const addList = useListStore((s) => s.addList);
|
||||
const removeFromList = useListStore((s) => s.removeFromList);
|
||||
const addItems = useListStore((s) => s.addItems);
|
||||
|
||||
|
||||
|
||||
const lastItemTimestampRef = useRef<null | number>(null)
|
||||
|
||||
useEffect(()=> {
|
||||
if(list?.length > 0){
|
||||
lastItemTimestampRef.current = list[0]?.created || null
|
||||
}
|
||||
}, [list])
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const getResourceList = useCallback(async () => {
|
||||
try {
|
||||
if(listOfResources?.length === 0){
|
||||
setIsLoading(false);
|
||||
return
|
||||
}
|
||||
setIsLoading(true);
|
||||
await new Promise((res)=> {
|
||||
setTimeout(() => {
|
||||
res(null)
|
||||
}, 500);
|
||||
})
|
||||
|
||||
lastItemTimestampRef.current = null
|
||||
const parsedParams = {limit, offset: 0};
|
||||
const responseData = await lists.fetchPreloadedResources(parsedParams, listOfResources, listName, returnType, true); // Awaiting the async function
|
||||
addList(listName, responseData || []);
|
||||
if(onNewData){
|
||||
onNewData(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch resources:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [listOfResources, lists.fetchPreloadedResources]); // Added dependencies for re-fetching
|
||||
|
||||
const resetSearch = useCallback(async ()=> {
|
||||
lists.deleteList(listName);
|
||||
getResourceList()
|
||||
}, [listName, getResourceList])
|
||||
|
||||
useEffect(()=> {
|
||||
if(ref){
|
||||
ref.current = {resetSearch}
|
||||
}
|
||||
}, [resetSearch])
|
||||
useEffect(() => {
|
||||
if(!listName || listOfResources?.length === 0){
|
||||
if(listName){
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
const isExpired = useCacheStore.getState().isListExpired(listName);
|
||||
if(typeof isExpired === 'string') {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
sessionStorage.removeItem(`scroll-position-${listName}`);
|
||||
getResourceList();
|
||||
}, [getResourceList, listName, listOfResources, limit]);
|
||||
|
||||
const {elementRef} = useScrollTracker(listName, list?.length > 0, scrollerRef ? true : !disableVirtualization ? true : disableScrollTracker);
|
||||
useScrollTrackerRef(listName, list?.length > 0, scrollerRef)
|
||||
const setSearchCacheExpiryDuration = useCacheStore((s) => s.setSearchCacheExpiryDuration);
|
||||
const setResourceCacheExpiryDuration = useCacheStore((s) => s.setResourceCacheExpiryDuration);
|
||||
|
||||
useEffect(()=> {
|
||||
if(searchCacheDuration){
|
||||
setSearchCacheExpiryDuration(searchCacheDuration)
|
||||
}
|
||||
}, [])
|
||||
useEffect(()=> {
|
||||
if(resourceCacheDuration){
|
||||
setResourceCacheExpiryDuration(resourceCacheDuration)
|
||||
}
|
||||
}, [])
|
||||
const listToDisplay = useMemo(()=> {
|
||||
return filterOutDeletedResources([...temporaryResources, ...(list || [])])
|
||||
}, [list, listName, deletedResources, temporaryResources])
|
||||
|
||||
useEffect(()=> {
|
||||
if(onResults){
|
||||
onResults({
|
||||
resourceItems: listToDisplay,
|
||||
isLoadingList: isLoading
|
||||
})
|
||||
}
|
||||
}, [listToDisplay, onResults, isLoading])
|
||||
|
||||
|
||||
const getResourceMoreList = useCallback(async (displayLimit?: number) => {
|
||||
try {
|
||||
if(listOfResources.length === 0 || limit === 0) return
|
||||
|
||||
const parsedParams = {limit, offset: listToDisplay?.length + (limit || 20)};
|
||||
|
||||
|
||||
const responseData = await lists.fetchPreloadedResources(parsedParams, listOfResources, listName, returnType); // Awaiting the async function
|
||||
addItems(listName, responseData || [])
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch resources:", error);
|
||||
}
|
||||
}, [listOfResources, listName, limit, listToDisplay]);
|
||||
|
||||
|
||||
|
||||
const disabledVirutalizationStyles: CSSProperties = useMemo(() => {
|
||||
if (styles?.disabledVirutalizationStyles?.parentContainer)
|
||||
return styles?.disabledVirutalizationStyles.parentContainer;
|
||||
return {
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${styles.gap}px` || 0,
|
||||
width: "100%",
|
||||
};
|
||||
}, [styles?.disabledVirutalizationStyles, styles?.gap, direction]);
|
||||
|
||||
useEffect(() => {
|
||||
const clearOnReload = () => {
|
||||
sessionStorage.removeItem(`scroll-position-${listName}`);
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", clearOnReload);
|
||||
return () => window.removeEventListener("beforeunload", clearOnReload);
|
||||
}, [listName]);
|
||||
|
||||
|
||||
const renderListItem = useCallback((item: ListItem, index: number) => {
|
||||
return listItem(item, index);
|
||||
}, [ listItem]);
|
||||
|
||||
const onLoadLess = useCallback((displayLimit: number)=> {
|
||||
removeFromList(listName, displayLimit)
|
||||
}, [removeFromList])
|
||||
const onLoadMore = useCallback((displayLimit: number)=> {
|
||||
getResourceMoreList(displayLimit)
|
||||
}, [getResourceMoreList])
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div ref={elementRef} style={{
|
||||
width: '100%',
|
||||
height: disableVirtualization ? 'auto' : '100%'
|
||||
}}>
|
||||
<ListLoader
|
||||
noResultsMessage={
|
||||
defaultLoaderParams?.listNoResultsText || "No results available"
|
||||
}
|
||||
resultsLength={listToDisplay?.length}
|
||||
isLoading={isLoading}
|
||||
loadingMessage={
|
||||
defaultLoaderParams?.listLoadingText || "Retrieving list. Please wait."
|
||||
}
|
||||
loaderList={loaderList}
|
||||
loaderHeight={styles?.listLoadingHeight}
|
||||
>
|
||||
<div
|
||||
|
||||
style={{
|
||||
height: disableVirtualization ? 'auto' : "100%",
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", flexGrow: 1 }}>
|
||||
{!disableVirtualization && (
|
||||
<VirtualizedList listName={listName} list={listToDisplay} onSeenLastItem={(item)=> {
|
||||
getResourceMoreList()
|
||||
if(onSeenLastItem){
|
||||
onSeenLastItem(item)
|
||||
}
|
||||
}}>
|
||||
{(item: QortalMetadata, index: number) => (
|
||||
<>
|
||||
{styles?.gap && <Spacer height={`${styles.gap / 2}px`} />}
|
||||
<Spacer />
|
||||
<ListItemWrapper
|
||||
defaultLoaderParams={defaultLoaderParams}
|
||||
item={item}
|
||||
index={index}
|
||||
render={renderListItem}
|
||||
renderListItemLoader={loaderItem}
|
||||
/>
|
||||
{styles?.gap && <Spacer height={`${styles.gap / 2}px`} />}
|
||||
</>
|
||||
)}
|
||||
</VirtualizedList>
|
||||
)}
|
||||
{disableVirtualization && direction === "HORIZONTAL" && (
|
||||
<>
|
||||
<HorizontalPaginatedList defaultLoaderParams={defaultLoaderParams} disablePagination={disablePagination} limit={limit || 20} onLoadLess={onLoadLess} items={listToDisplay} listItem={renderListItem} onLoadMore={onLoadMore} gap={styles?.gap} minItemWidth={styles?.horizontalStyles?.minItemWidth} loaderItem={loaderItem} />
|
||||
</>
|
||||
|
||||
)}
|
||||
{disableVirtualization && direction === "VERTICAL" && (
|
||||
<div style={disabledVirutalizationStyles}>
|
||||
<VerticalPaginatedList disablePagination={disablePagination} limit={limit || 20} onLoadLess={(displayLimit)=> {
|
||||
|
||||
removeFromList(listName, displayLimit)
|
||||
}} defaultLoaderParams={defaultLoaderParams} items={listToDisplay} listItem={renderListItem} onLoadMore={(displayLimit)=> getResourceMoreList(displayLimit)} loaderItem={loaderItem} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ListLoader>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function arePropsEqual(
|
||||
prevProps: PropsResourceListDisplay,
|
||||
nextProps: PropsResourceListDisplay
|
||||
): boolean {
|
||||
return (
|
||||
prevProps.listName === nextProps.listName &&
|
||||
prevProps.disableVirtualization === nextProps.disableVirtualization &&
|
||||
prevProps.direction === nextProps.direction &&
|
||||
prevProps.onSeenLastItem === nextProps.onSeenLastItem &&
|
||||
JSON.stringify(prevProps.styles) === JSON.stringify(nextProps.styles) &&
|
||||
prevProps.listItem === nextProps.listItem
|
||||
);
|
||||
}
|
||||
|
||||
export const ResourceListPreloadedDisplay = React.memo(MemorizedComponent, arePropsEqual);
|
||||
|
||||
interface ListItemWrapperProps {
|
||||
item: QortalMetadata;
|
||||
index: number;
|
||||
render: (item: ListItem, index: number) => React.ReactNode;
|
||||
defaultLoaderParams?: DefaultLoaderParams;
|
||||
renderListItemLoader?: (status: "LOADING" | "ERROR") => React.ReactNode;
|
||||
}
|
||||
|
||||
export const ListItemWrapper: React.FC<ListItemWrapperProps> = ({
|
||||
item,
|
||||
index,
|
||||
render,
|
||||
defaultLoaderParams,
|
||||
renderListItemLoader,
|
||||
}) => {
|
||||
const resourceKey = `${item.service}-${item.name}-${item.identifier}`;
|
||||
|
||||
const entry = useCacheStore((s) => s.resourceCache[resourceKey]);
|
||||
const [validResource, setValidResource] = useState(entry?.data ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!entry) return setValidResource(null);
|
||||
|
||||
if (entry.expiry > Date.now()) {
|
||||
setValidResource(entry.data);
|
||||
} else {
|
||||
useCacheStore.setState((s) => {
|
||||
const newCache = { ...s.resourceCache };
|
||||
delete newCache[resourceKey];
|
||||
return { resourceCache: newCache };
|
||||
});
|
||||
setValidResource(null);
|
||||
}
|
||||
}, [entry, resourceKey]);
|
||||
|
||||
if (validResource === null && !renderListItemLoader)
|
||||
return (
|
||||
<ItemCardWrapper height={60} isInCart={false}>
|
||||
<ResourceLoader
|
||||
message={
|
||||
defaultLoaderParams?.listItemLoadingText || "Fetching Data..."
|
||||
}
|
||||
status="loading"
|
||||
/>
|
||||
</ItemCardWrapper>
|
||||
);
|
||||
if (validResource === false && !renderListItemLoader)
|
||||
return (
|
||||
<ItemCardWrapper height={60} isInCart={false}>
|
||||
<ResourceLoader
|
||||
message={
|
||||
defaultLoaderParams?.listItemErrorText ||
|
||||
"Resource is unavailble at this moment... Try again later."
|
||||
}
|
||||
status="error"
|
||||
/>
|
||||
</ItemCardWrapper>
|
||||
);
|
||||
if (
|
||||
renderListItemLoader &&
|
||||
(validResource === false || validResource === null)
|
||||
) {
|
||||
return renderListItemLoader(
|
||||
validResource === null ? "LOADING" : "ERROR"
|
||||
);
|
||||
}
|
||||
|
||||
// Example transformation (Modify item if needed)
|
||||
const transformedItem = validResource
|
||||
? validResource
|
||||
: { qortalMetadata: item, data: null };
|
||||
|
||||
return <>{render(transformedItem, index)}</>;
|
||||
};
|
||||
@@ -23,7 +23,7 @@ interface VerticalPaginatedListProps {
|
||||
defaultLoaderParams?: DefaultLoaderParams;
|
||||
}
|
||||
|
||||
export const VerticalPaginatedList = ({
|
||||
const MemorizedComponent = ({
|
||||
items,
|
||||
listItem,
|
||||
loaderItem,
|
||||
@@ -111,3 +111,5 @@ export const VerticalPaginatedList = ({
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const VerticalPaginatedList = React.memo(MemorizedComponent);
|
||||
|
||||
22
src/components/VideoPlayer/CustomFontTooltip.tsx
Normal file
22
src/components/VideoPlayer/CustomFontTooltip.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Box, Tooltip, TooltipProps } from "@mui/material";
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
export interface CustomFontTooltipProps extends TooltipProps {
|
||||
fontSize?: string;
|
||||
}
|
||||
export const CustomFontTooltip = ({
|
||||
fontSize,
|
||||
title,
|
||||
children,
|
||||
...props
|
||||
}: PropsWithChildren<CustomFontTooltipProps>) => {
|
||||
if (!fontSize) fontSize = "160%";
|
||||
const text = <Box sx={{ fontSize: fontSize }}>{title}</Box>;
|
||||
|
||||
// put controls into individual components
|
||||
return (
|
||||
<Tooltip title={text} {...props} sx={{ display: "contents", ...props.sx }}>
|
||||
<div>{children}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
58
src/components/VideoPlayer/LanguageSelect.tsx
Normal file
58
src/components/VideoPlayer/LanguageSelect.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
// 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
|
||||
size="small"
|
||||
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
|
||||
required
|
||||
{...params}
|
||||
label="Subtitle Language"
|
||||
sx={{
|
||||
fontSize: '1rem', // Input text size
|
||||
'& .MuiInputBase-input': {
|
||||
fontSize: '1rem', // Inner input
|
||||
},
|
||||
'& .MuiInputLabel-root': {
|
||||
fontSize: '0.75rem', // Label text
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
isOptionEqualToValue={(option, val) => option.code === val.code}
|
||||
sx={{
|
||||
width: '100%',
|
||||
fontSize: '1rem', // affects root font size
|
||||
'& .MuiAutocomplete-input': {
|
||||
fontSize: '1rem',
|
||||
},
|
||||
}}
|
||||
slotProps={{
|
||||
popper: {
|
||||
sx: {
|
||||
zIndex: 999991,
|
||||
'& .MuiAutocomplete-paper': {
|
||||
fontSize: '1rem', // dropdown font size
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
);
|
||||
}
|
||||
139
src/components/VideoPlayer/LoadingVideo.tsx
Normal file
139
src/components/VideoPlayer/LoadingVideo.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
alpha,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
|
||||
import { PlayArrow } from "@mui/icons-material";
|
||||
import { Status } from "../../state/publishes";
|
||||
|
||||
interface LoadingVideoProps {
|
||||
status: Status | null;
|
||||
percentLoaded: number;
|
||||
isReady: boolean;
|
||||
isLoading: boolean;
|
||||
togglePlay: () => void;
|
||||
startPlay: boolean;
|
||||
downloadResource: () => void;
|
||||
isStatusWrong: boolean
|
||||
}
|
||||
export const LoadingVideo = ({
|
||||
status,
|
||||
percentLoaded,
|
||||
isReady,
|
||||
isLoading,
|
||||
togglePlay,
|
||||
startPlay,
|
||||
downloadResource,
|
||||
isStatusWrong
|
||||
}: LoadingVideoProps) => {
|
||||
const getDownloadProgress = (percentLoaded: number) => {
|
||||
const progress = percentLoaded;
|
||||
return Number.isNaN(progress) ? "" : progress.toFixed(1) + "%";
|
||||
};
|
||||
if (status === "READY") return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && status !== "INITIAL" && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
zIndex={500}
|
||||
bgcolor={alpha("#000000", !startPlay ? 0 : 0.95)}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "10px",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{status !== "NOT_PUBLISHED" && status !== "FAILED_TO_DOWNLOAD" && (
|
||||
<CircularProgress
|
||||
sx={{
|
||||
color: "white",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{status && (
|
||||
<Typography
|
||||
component="div"
|
||||
sx={{
|
||||
color: "white",
|
||||
fontSize: "15px",
|
||||
textAlign: "center",
|
||||
fontFamily: "sans-serif",
|
||||
}}
|
||||
>
|
||||
{status === "NOT_PUBLISHED" ? (
|
||||
<>Video file was not published. Please inform the publisher!</>
|
||||
) : status === "REFETCHING" ? (
|
||||
<>
|
||||
<>{getDownloadProgress(percentLoaded)}</>
|
||||
|
||||
<> Refetching in 10 seconds</>
|
||||
</>
|
||||
) : (status === "DOWNLOADED" && !isStatusWrong) ? (
|
||||
<>Download Completed: building video...</>
|
||||
) : status === "FAILED_TO_DOWNLOAD" ? (
|
||||
<>Unable to fetch video chunks from peers</>
|
||||
) : (
|
||||
<>{getDownloadProgress(percentLoaded)}</>
|
||||
)}
|
||||
</Typography>
|
||||
)}
|
||||
{status === "FAILED_TO_DOWNLOAD" && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={downloadResource}
|
||||
sx={{
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{status === "INITIAL" && (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
togglePlay();
|
||||
}}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 501,
|
||||
background: "rgba(0,0,0,0.3)",
|
||||
padding: "0px",
|
||||
borderRadius: "0px",
|
||||
}}
|
||||
>
|
||||
<PlayArrow
|
||||
sx={{
|
||||
width: "50px",
|
||||
height: "50px",
|
||||
color: "white",
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
266
src/components/VideoPlayer/MobileControls.tsx
Normal file
266
src/components/VideoPlayer/MobileControls.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { alpha, Box, IconButton } from "@mui/material";
|
||||
import React, { CSSProperties } from "react";
|
||||
import { ProgressSlider, VideoTime } from "./VideoControls";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import PauseIcon from "@mui/icons-material/Pause";
|
||||
import SubtitlesIcon from "@mui/icons-material/Subtitles";
|
||||
import SlowMotionVideoIcon from "@mui/icons-material/SlowMotionVideo";
|
||||
import Fullscreen from "@mui/icons-material/Fullscreen";
|
||||
import Forward10Icon from "@mui/icons-material/Forward10";
|
||||
import Replay10Icon from "@mui/icons-material/Replay10";
|
||||
|
||||
interface MobileControlsProps {
|
||||
showControlsMobile: boolean;
|
||||
progress: number;
|
||||
duration: number;
|
||||
playerRef: any;
|
||||
setShowControlsMobile: (val: boolean) => void;
|
||||
isPlaying: boolean;
|
||||
togglePlay: () => void;
|
||||
openSubtitleManager: () => void;
|
||||
openPlaybackMenu: () => void;
|
||||
toggleFullscreen: () => void;
|
||||
setProgressRelative: (val: number) => void;
|
||||
setLocalProgress: (val: number) => void;
|
||||
resetHideTimeout: () => void;
|
||||
styling?: {
|
||||
progressSlider?: {
|
||||
thumbColor?: CSSProperties["color"];
|
||||
railColor?: CSSProperties["color"];
|
||||
trackColor?: CSSProperties["color"];
|
||||
};
|
||||
};
|
||||
}
|
||||
export const MobileControls = ({
|
||||
showControlsMobile,
|
||||
togglePlay,
|
||||
isPlaying,
|
||||
setShowControlsMobile,
|
||||
playerRef,
|
||||
progress,
|
||||
duration,
|
||||
openSubtitleManager,
|
||||
openPlaybackMenu,
|
||||
toggleFullscreen,
|
||||
setProgressRelative,
|
||||
setLocalProgress,
|
||||
resetHideTimeout,
|
||||
styling
|
||||
}: MobileControlsProps) => {
|
||||
return (
|
||||
<Box
|
||||
onClick={() => setShowControlsMobile(false)}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
display: showControlsMobile ? "block" : "none",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
zIndex: 1,
|
||||
background: "rgba(0,0,0,.5)",
|
||||
opacity: 1,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "10px",
|
||||
right: "10px",
|
||||
display: "flex",
|
||||
gap: "10px",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openSubtitleManager();
|
||||
}}
|
||||
sx={{
|
||||
background: "rgba(0,0,0,0.3)",
|
||||
borderRadius: "50%",
|
||||
padding: "7px",
|
||||
}}
|
||||
>
|
||||
<SubtitlesIcon
|
||||
sx={{
|
||||
fontSize: "24px",
|
||||
color: "white",
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
sx={{
|
||||
background: "rgba(0,0,0,0.3)",
|
||||
borderRadius: "50%",
|
||||
padding: "7px",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openPlaybackMenu();
|
||||
}}
|
||||
>
|
||||
<SlowMotionVideoIcon
|
||||
sx={{
|
||||
fontSize: "24px",
|
||||
color: "white",
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
gap: "50px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
sx={{
|
||||
opacity: 1,
|
||||
zIndex: 2,
|
||||
background: "rgba(0,0,0,0.3)",
|
||||
borderRadius: "50%",
|
||||
padding: "10px",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setProgressRelative(-10);
|
||||
}}
|
||||
>
|
||||
<Replay10Icon
|
||||
sx={{
|
||||
fontSize: "36px",
|
||||
color: "white",
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
{isPlaying && (
|
||||
<IconButton
|
||||
sx={{
|
||||
opacity: 1,
|
||||
zIndex: 2,
|
||||
background: "rgba(0,0,0,0.3)",
|
||||
borderRadius: "50%",
|
||||
padding: "10px",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePlay();
|
||||
}}
|
||||
>
|
||||
<PauseIcon
|
||||
sx={{
|
||||
fontSize: "36px",
|
||||
color: "white",
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
{!isPlaying && (
|
||||
<IconButton
|
||||
sx={{
|
||||
opacity: 1,
|
||||
zIndex: 2,
|
||||
background: "rgba(0,0,0,0.3)",
|
||||
borderRadius: "50%",
|
||||
padding: "10px",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePlay();
|
||||
}}
|
||||
>
|
||||
<PlayArrowIcon
|
||||
sx={{
|
||||
fontSize: "36px",
|
||||
color: "white",
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
sx={{
|
||||
opacity: 1,
|
||||
zIndex: 2,
|
||||
background: "rgba(0,0,0,0.3)",
|
||||
borderRadius: "50%",
|
||||
padding: "10px",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setProgressRelative(10);
|
||||
}}
|
||||
>
|
||||
<Forward10Icon
|
||||
sx={{
|
||||
fontSize: "36px",
|
||||
color: "white",
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: "20px",
|
||||
right: "10px",
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
sx={{
|
||||
fontSize: "24px",
|
||||
background: "rgba(0,0,0,0.3)",
|
||||
borderRadius: "50%",
|
||||
padding: "7px",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFullscreen();
|
||||
}}
|
||||
>
|
||||
<Fullscreen
|
||||
sx={{
|
||||
color: "white",
|
||||
fontSize: "24px",
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
position: "absolute",
|
||||
bottom: '5px',
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
padding: "0px 10px",
|
||||
}}
|
||||
>
|
||||
<VideoTime isScreenSmall progress={progress} duration={duration} />
|
||||
</Box>
|
||||
<ProgressSlider
|
||||
playerRef={playerRef}
|
||||
progress={progress}
|
||||
duration={duration}
|
||||
setLocalProgress={setLocalProgress}
|
||||
setShowControlsMobile={setShowControlsMobile}
|
||||
resetHideTimeout={resetHideTimeout}
|
||||
thumbColor={styling?.progressSlider?.thumbColor}
|
||||
railColor={styling?.progressSlider?.railColor}
|
||||
trackColor={styling?.progressSlider?.trackColor}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
918
src/components/VideoPlayer/SubtitleManager.tsx
Normal file
918
src/components/VideoPlayer/SubtitleManager.tsx
Normal file
@@ -0,0 +1,918 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
QortalGetMetadata,
|
||||
QortalMetadata,
|
||||
Service,
|
||||
} from "../../types/interfaces/resources";
|
||||
import {
|
||||
alpha,
|
||||
Box,
|
||||
Button,
|
||||
ButtonBase,
|
||||
Card,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
Fade,
|
||||
IconButton,
|
||||
Popover,
|
||||
Skeleton,
|
||||
Tab,
|
||||
Tabs,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
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 { useListStore } from "../../state/lists";
|
||||
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 DownloadIcon from "@mui/icons-material/Download";
|
||||
import DownloadingIcon from "@mui/icons-material/Downloading";
|
||||
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";
|
||||
import { Spacer } from "../../common/Spacer";
|
||||
import {
|
||||
dismissToast,
|
||||
showError,
|
||||
showLoading,
|
||||
showSuccess,
|
||||
} from "../../utils/toast";
|
||||
import { RequestQueueWithPromise } from "../../utils/queue";
|
||||
import { useLibTranslation } from "../../hooks/useLibTranslation";
|
||||
|
||||
export const requestQueueGetStatus = new RequestQueueWithPromise(1);
|
||||
|
||||
export interface SubtitleManagerProps {
|
||||
qortalMetadata: QortalGetMetadata;
|
||||
close: () => void;
|
||||
open: boolean;
|
||||
onSelect: (subtitle: SubtitlePublishedData) => void;
|
||||
subtitleBtnRef: any;
|
||||
currentSubTrack: null | string;
|
||||
setDrawerOpenSubtitles: (val: boolean) => void;
|
||||
isFromDrawer: boolean;
|
||||
exitFullscreen: () => void;
|
||||
}
|
||||
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 languageOptions = ISO6391.getAllCodes().map((code) => ({
|
||||
code,
|
||||
name: ISO6391.getName(code),
|
||||
nativeName: ISO6391.getNativeName(code),
|
||||
}));
|
||||
|
||||
function a11yProps(index: number) {
|
||||
return {
|
||||
id: `subtitle-tab-${index}`,
|
||||
"aria-controls": `subtitle-tabpanel-${index}`,
|
||||
};
|
||||
}
|
||||
|
||||
const SubtitleManagerComponent = ({
|
||||
qortalMetadata,
|
||||
open,
|
||||
close,
|
||||
onSelect,
|
||||
subtitleBtnRef,
|
||||
currentSubTrack,
|
||||
setDrawerOpenSubtitles,
|
||||
isFromDrawer = false,
|
||||
exitFullscreen,
|
||||
}: SubtitleManagerProps) => {
|
||||
const { t } = useLibTranslation();
|
||||
|
||||
const [mode, setMode] = useState(1);
|
||||
const [isOpenPublish, setIsOpenPublish] = useState(false);
|
||||
const { lists, identifierOperations, auth } = useGlobal();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const { fetchResources } = useResources();
|
||||
// const [subtitles, setSubtitles] = useState([])
|
||||
const subtitles = useListReturn(
|
||||
`subs-${qortalMetadata?.service}-${qortalMetadata?.name}-${qortalMetadata?.identifier}`
|
||||
);
|
||||
const mySubtitles = useMemo(() => {
|
||||
if (!auth?.name) return [];
|
||||
return subtitles?.filter((sub) => sub.name === auth?.name);
|
||||
}, [subtitles, auth?.name]);
|
||||
const getPublishedSubtitles = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const videoId = `${qortalMetadata?.service}-${qortalMetadata?.name}-${qortalMetadata?.identifier}`;
|
||||
const postIdSearch = await identifierOperations.buildLooseSearchPrefix(
|
||||
ENTITY_SUBTITLE,
|
||||
videoId
|
||||
);
|
||||
let name: string | undefined = qortalMetadata?.name;
|
||||
if (showAll) {
|
||||
name = undefined;
|
||||
}
|
||||
const searchParams = {
|
||||
service: SERVICE_SUBTITLE,
|
||||
identifier: postIdSearch,
|
||||
name,
|
||||
limit: 0,
|
||||
includeMetadata: true,
|
||||
};
|
||||
const res = await lists.fetchResourcesResultsOnly(searchParams);
|
||||
lists.addList(
|
||||
`subs-${videoId}`,
|
||||
res?.filter((item) => !!item?.metadata?.title) || []
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [showAll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!qortalMetadata?.identifier ||
|
||||
!qortalMetadata?.name ||
|
||||
!qortalMetadata?.service
|
||||
)
|
||||
return;
|
||||
|
||||
getPublishedSubtitles();
|
||||
}, [
|
||||
qortalMetadata?.identifier,
|
||||
qortalMetadata?.service,
|
||||
qortalMetadata?.name,
|
||||
getPublishedSubtitles,
|
||||
]);
|
||||
|
||||
const ref = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
ref?.current?.focus();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleBlur = (e: React.FocusEvent) => {
|
||||
if (
|
||||
!e.currentTarget.contains(e.relatedTarget) &&
|
||||
!isOpenPublish &&
|
||||
!isFromDrawer &&
|
||||
open
|
||||
) {
|
||||
close();
|
||||
setIsOpenPublish(false);
|
||||
}
|
||||
};
|
||||
|
||||
const publishHandler = async (subtitles: Subtitle[]) => {
|
||||
try {
|
||||
const videoId = `${qortalMetadata?.service}-${qortalMetadata?.name}-${qortalMetadata?.identifier}`;
|
||||
|
||||
const name = auth?.name;
|
||||
if (!name) return;
|
||||
const resources: ResourceToPublish[] = [];
|
||||
const tempResources: { qortalMetadata: QortalMetadata; data: any }[] = [];
|
||||
for (const sub of subtitles) {
|
||||
const identifier = await identifierOperations.buildLooseIdentifier(
|
||||
ENTITY_SUBTITLE,
|
||||
videoId
|
||||
);
|
||||
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(),
|
||||
metadata: {
|
||||
title: sub.language || undefined,
|
||||
},
|
||||
},
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
const theme = useTheme();
|
||||
if (!open) return null;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 9, // one layer below MUI drawer
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Box
|
||||
ref={ref}
|
||||
tabIndex={-1}
|
||||
onBlur={handleBlur}
|
||||
bgcolor={alpha("#181818", 0.98)}
|
||||
sx={{
|
||||
position: isFromDrawer ? "relative" : "absolute",
|
||||
bottom: isFromDrawer ? "unset" : 60,
|
||||
right: isFromDrawer ? "unset" : 5,
|
||||
color: "white",
|
||||
opacity: 0.9,
|
||||
borderRadius: 2,
|
||||
boxShadow: isFromDrawer ? "unset" : 5,
|
||||
p: 1,
|
||||
minWidth: 225,
|
||||
height: 300,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
{t("subtitle.subtitles")}
|
||||
</Typography>
|
||||
</ButtonBase>
|
||||
<ButtonBase
|
||||
sx={{
|
||||
marginLeft: "auto",
|
||||
}}
|
||||
onClick={() => {
|
||||
setIsOpenPublish(true);
|
||||
}}
|
||||
>
|
||||
<ModeEditIcon
|
||||
sx={{
|
||||
fontSize: "1.15rem",
|
||||
}}
|
||||
/>
|
||||
</ButtonBase>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flexGrow: 1,
|
||||
overflow: "auto",
|
||||
"::-webkit-scrollbar-track": {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
|
||||
"::-webkit-scrollbar": {
|
||||
width: "16px",
|
||||
height: "10px",
|
||||
},
|
||||
|
||||
"::-webkit-scrollbar-thumb": {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
borderRadius: "8px",
|
||||
backgroundClip: "content-box",
|
||||
border: "4px solid transparent",
|
||||
transition: "0.3s background-color",
|
||||
},
|
||||
|
||||
"::-webkit-scrollbar-thumb:hover": {
|
||||
backgroundColor: theme.palette.primary.dark,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isLoading && <CircularProgress />}
|
||||
{!isLoading && subtitles?.length === 0 && (
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: "1rem",
|
||||
width: "100%",
|
||||
textAlign: "center",
|
||||
marginTop: "20px",
|
||||
}}
|
||||
>
|
||||
{t("subtitle.no_subtitles")}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{mode === 1 && !isLoading && subtitles?.length > 0 && (
|
||||
<PublisherSubtitles
|
||||
subtitles={subtitles}
|
||||
publisherName={qortalMetadata.name}
|
||||
setMode={setMode}
|
||||
onSelect={onSelectHandler}
|
||||
onBack={onBack}
|
||||
currentSubTrack={currentSubTrack}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={showAll}
|
||||
onClick={() => setShowAll(true)}
|
||||
>
|
||||
{t("subtitle.load_community_subs")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<PublishSubtitles
|
||||
isOpen={isOpenPublish}
|
||||
setIsOpen={setIsOpenPublish}
|
||||
publishHandler={publishHandler}
|
||||
mySubtitles={mySubtitles}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface PublisherSubtitlesProps {
|
||||
publisherName: string;
|
||||
subtitles: any[];
|
||||
setMode: (val: number) => void;
|
||||
onSelect: (subtitle: any) => void;
|
||||
onBack: () => void;
|
||||
currentSubTrack: string | null;
|
||||
}
|
||||
|
||||
const PublisherSubtitles = ({
|
||||
publisherName,
|
||||
subtitles,
|
||||
setMode,
|
||||
onSelect,
|
||||
onBack,
|
||||
currentSubTrack,
|
||||
}: PublisherSubtitlesProps) => {
|
||||
const { t } = useLibTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ButtonBase
|
||||
disabled={!currentSubTrack}
|
||||
onClick={() => onSelect(null)}
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
},
|
||||
width: "100%",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Typography>{t("subtitle.off")}</Typography>
|
||||
{!currentSubTrack ? <CheckIcon /> : <ArrowForwardIosIcon />}
|
||||
</ButtonBase>
|
||||
|
||||
{subtitles?.map((sub, i) => {
|
||||
return (
|
||||
<Subtitle
|
||||
currentSubtrack={currentSubTrack}
|
||||
onSelect={onSelect}
|
||||
sub={sub}
|
||||
key={i}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface PublishSubtitlesProps {
|
||||
publishHandler: (subs: Subtitle[]) => Promise<void>;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (val: boolean) => void;
|
||||
mySubtitles: QortalGetMetadata[];
|
||||
}
|
||||
|
||||
const PublishSubtitles = ({
|
||||
publishHandler,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
mySubtitles,
|
||||
}: PublishSubtitlesProps) => {
|
||||
const { t } = useLibTranslation();
|
||||
|
||||
const [language, setLanguage] = useState<null | string>(null);
|
||||
const [subtitles, setSubtitles] = useState<Subtitle[]>([]);
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const { lists } = useGlobal();
|
||||
const theme = useTheme();
|
||||
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 convert to base64:", 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;
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
setSubtitles([]);
|
||||
};
|
||||
|
||||
const [value, setValue] = useState(0);
|
||||
|
||||
const handleChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||
setValue(newValue);
|
||||
};
|
||||
|
||||
const onDelete = useCallback(async (sub: QortalGetMetadata) => {
|
||||
let loadId;
|
||||
try {
|
||||
setIsPublishing(true);
|
||||
loadId = showLoading(t("subtitle.deleting_subtitle"));
|
||||
await lists.deleteResource([sub]);
|
||||
showSuccess(t("subtitle.deleted"));
|
||||
} catch (error) {
|
||||
showError(
|
||||
error instanceof Error ? error.message : t("subtitle.unable_delete")
|
||||
);
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
dismissToast(loadId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const publishHandlerLocal = async (subtitles: Subtitle[]) => {
|
||||
let loadId;
|
||||
try {
|
||||
setIsPublishing(true);
|
||||
loadId = showLoading(t("subtitle.publishing"));
|
||||
await publishHandler(subtitles);
|
||||
showSuccess(t("subtitle.published"));
|
||||
setSubtitles([]);
|
||||
} catch (error) {
|
||||
showError(
|
||||
error instanceof Error ? error.message : t("subtitle.unable_publish")
|
||||
);
|
||||
} finally {
|
||||
dismissToast(loadId);
|
||||
setIsPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disableButton =
|
||||
!!subtitles.find((sub) => !sub?.language) || isPublishing;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
fullWidth={true}
|
||||
maxWidth={"md"}
|
||||
disablePortal={true}
|
||||
sx={{
|
||||
zIndex: 999990,
|
||||
}}
|
||||
slotProps={{
|
||||
paper: {
|
||||
elevation: 0,
|
||||
sx: {
|
||||
height: "600px",
|
||||
maxHeight: "100vh",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle>{t("subtitle.my_subtitles")}</DialogTitle>
|
||||
<IconButton
|
||||
aria-label="close"
|
||||
onClick={handleClose}
|
||||
sx={(theme) => ({
|
||||
position: "absolute",
|
||||
right: 8,
|
||||
top: 8,
|
||||
})}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<DialogContent
|
||||
sx={{
|
||||
"::-webkit-scrollbar": {
|
||||
width: "16px",
|
||||
height: "10px",
|
||||
},
|
||||
|
||||
"::-webkit-scrollbar-thumb": {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
borderRadius: "8px",
|
||||
backgroundClip: "content-box",
|
||||
border: "4px solid transparent",
|
||||
transition: "0.3s background-color",
|
||||
},
|
||||
|
||||
"::-webkit-scrollbar-thumb:hover": {
|
||||
backgroundColor: theme.palette.primary.dark,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||
<Tabs
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
aria-label="basic tabs example"
|
||||
>
|
||||
<Tab label={t("subtitle.new")} {...a11yProps(0)} />
|
||||
<Tab label={t("subtitle.existing")} {...a11yProps(1)} />
|
||||
</Tabs>
|
||||
</Box>
|
||||
</Box>
|
||||
<Spacer height="25px" />
|
||||
{value === 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "20px",
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Box {...getRootProps()}>
|
||||
<Button
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: "10px",
|
||||
}}
|
||||
variant="contained"
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{t("subtitle.import_subtitles")}
|
||||
</Button>
|
||||
</Box>
|
||||
{subtitles?.map((sub, i) => {
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
padding: "10px",
|
||||
width: "500px",
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{sub.filename}
|
||||
</Typography>
|
||||
<Spacer height="10px" />
|
||||
<LanguageSelect
|
||||
value={sub.language}
|
||||
onChange={(val: string | null) =>
|
||||
onChangeValue("language", val, i)
|
||||
}
|
||||
/>
|
||||
<Spacer height="10px" />
|
||||
<Box
|
||||
sx={{
|
||||
justifyContent: "flex-end",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSubtitles((prev) => {
|
||||
const newSubtitles = [...prev];
|
||||
newSubtitles.splice(i, 1); // Remove 1 item at index i
|
||||
return newSubtitles;
|
||||
});
|
||||
}}
|
||||
variant="contained"
|
||||
size="small"
|
||||
color="secondary"
|
||||
>
|
||||
{t("actions.remove")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
{value === 1 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "20px",
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{mySubtitles?.map((sub, i) => {
|
||||
return (
|
||||
<Card
|
||||
key={i}
|
||||
sx={{
|
||||
padding: "10px",
|
||||
width: "500px",
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
<MySubtitle onDelete={onDelete} sub={sub} />
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{value === 0 && (
|
||||
<Button
|
||||
onClick={() => publishHandlerLocal(subtitles)}
|
||||
disabled={disableButton}
|
||||
variant="contained"
|
||||
>
|
||||
{t("actions.publish")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
interface SubProps {
|
||||
sub: QortalMetadata;
|
||||
onSelect: (subtitle: Subtitle) => void;
|
||||
currentSubtrack: null | string;
|
||||
}
|
||||
const subtitlesStatus: Record<string, boolean> = {};
|
||||
|
||||
const Subtitle = ({ sub, onSelect, currentSubtrack }: SubProps) => {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const { resource, isLoading, error, refetch } = usePublish(
|
||||
2,
|
||||
"JSON",
|
||||
sub,
|
||||
true
|
||||
);
|
||||
const isSelected = currentSubtrack === resource?.data?.language;
|
||||
const [isGettingStatus, setIsGettingStatus] = useState(true);
|
||||
|
||||
const getStatus = useCallback(
|
||||
async (service: Service, name: string, identifier: string) => {
|
||||
try {
|
||||
if (subtitlesStatus[`${service}-${name}-${identifier}`]) {
|
||||
setIsReady(true);
|
||||
refetch();
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await requestQueueGetStatus.enqueue(
|
||||
(): Promise<string> => {
|
||||
return qortalRequest({
|
||||
action: "GET_QDN_RESOURCE_STATUS",
|
||||
identifier,
|
||||
service,
|
||||
name,
|
||||
build: false,
|
||||
});
|
||||
}
|
||||
);
|
||||
if (response?.status === "READY") {
|
||||
setIsReady(true);
|
||||
subtitlesStatus[`${service}-${name}-${identifier}`] = true;
|
||||
refetch();
|
||||
}
|
||||
} catch (error) {
|
||||
} finally {
|
||||
setIsGettingStatus(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (sub?.service && sub?.name && sub?.identifier) {
|
||||
getStatus(sub?.service, sub?.name, sub?.identifier);
|
||||
}
|
||||
}, [sub?.identifier, sub?.name, sub?.service]);
|
||||
|
||||
return (
|
||||
<ButtonBase
|
||||
onClick={() => {
|
||||
if (resource?.data) {
|
||||
onSelect(isSelected ? null : resource?.data);
|
||||
} else {
|
||||
refetch();
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
},
|
||||
width: "100%",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
{isGettingStatus && (
|
||||
<Skeleton variant="text" sx={{ fontSize: "1.25rem", width: "100%" }} />
|
||||
)}
|
||||
{!isGettingStatus && (
|
||||
<>
|
||||
<Typography>{sub?.metadata?.title}</Typography>
|
||||
{!isLoading && !error && !resource?.data ? (
|
||||
<DownloadIcon />
|
||||
) : isLoading ? (
|
||||
<DownloadingIcon />
|
||||
) : isSelected ? (
|
||||
<CheckIcon />
|
||||
) : (
|
||||
<ArrowForwardIosIcon />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ButtonBase>
|
||||
);
|
||||
};
|
||||
|
||||
interface MySubtitleProps {
|
||||
sub: QortalGetMetadata;
|
||||
onDelete: (subtitle: QortalGetMetadata) => void;
|
||||
}
|
||||
const MySubtitle = ({ sub, onDelete }: MySubtitleProps) => {
|
||||
const { t } = useLibTranslation();
|
||||
|
||||
const { resource, isLoading, error } = usePublish(2, "JSON", sub);
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
padding: "10px",
|
||||
width: "500px",
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{resource?.data?.filename}
|
||||
</Typography>
|
||||
<Spacer height="10px" />
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{resource?.data?.language}
|
||||
</Typography>
|
||||
<Spacer height="10px" />
|
||||
<Box
|
||||
sx={{
|
||||
justifyContent: "flex-end",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={() => onDelete(sub)}
|
||||
variant="contained"
|
||||
size="small"
|
||||
color="secondary"
|
||||
>
|
||||
{t("actions.delete")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export const SubtitleManager = React.memo(SubtitleManagerComponent);
|
||||
89
src/components/VideoPlayer/TimelineActionsComponent.tsx
Normal file
89
src/components/VideoPlayer/TimelineActionsComponent.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { TimelineAction } from "./VideoPlayer";
|
||||
import { alpha, ButtonBase, Typography } from "@mui/material";
|
||||
|
||||
interface TimelineActionsComponentProps {
|
||||
timelineActions: TimelineAction[];
|
||||
progress: number;
|
||||
containerRef: any;
|
||||
seekTo: (time: number) => void;
|
||||
isVideoPlayerSmall: boolean;
|
||||
}
|
||||
|
||||
const placementStyles: Record<
|
||||
NonNullable<TimelineAction["placement"]>,
|
||||
React.CSSProperties
|
||||
> = {
|
||||
"TOP-RIGHT": { top: 16, right: 16 },
|
||||
"TOP-LEFT": { top: 16, left: 16 },
|
||||
"BOTTOM-LEFT": { bottom: 60, left: 16 },
|
||||
"BOTTOM-RIGHT": { bottom: 60, right: 16 },
|
||||
};
|
||||
|
||||
export const TimelineActionsComponent = ({
|
||||
timelineActions,
|
||||
progress,
|
||||
containerRef,
|
||||
seekTo,
|
||||
isVideoPlayerSmall,
|
||||
}: TimelineActionsComponentProps) => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
const handleClick = useCallback((action: TimelineAction) => {
|
||||
if (action?.type === "SEEK") {
|
||||
if (!action?.seekToTime) return;
|
||||
seekTo(action.seekToTime);
|
||||
} else if (action?.type === "CUSTOM") {
|
||||
if (action.onClick) {
|
||||
action.onClick();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Find the current matching action(s)
|
||||
const activeActions = useMemo(() => {
|
||||
return timelineActions.filter((action) => {
|
||||
return (
|
||||
progress >= action.time && progress <= action.time + action.duration
|
||||
);
|
||||
});
|
||||
}, [timelineActions, progress]);
|
||||
|
||||
const hasActive = activeActions.length > 0;
|
||||
|
||||
if (!hasActive) return null; // Don’t render unless active
|
||||
return (
|
||||
<>
|
||||
{activeActions?.map((action, index) => {
|
||||
const placement = (action.placement ??
|
||||
"TOP-RIGHT") as keyof typeof placementStyles;
|
||||
|
||||
return (
|
||||
<ButtonBase
|
||||
key={index}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bgcolor: alpha("#181818", 0.95),
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
boxShadow: 3,
|
||||
zIndex: 10,
|
||||
outline: "1px solid white",
|
||||
...placementStyles[placement || "TOP-RIGHT"],
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
key={index}
|
||||
sx={{
|
||||
fontSize: isVideoPlayerSmall ? "16px" : "18px",
|
||||
}}
|
||||
onClick={() => handleClick(action)}
|
||||
>
|
||||
{action.label}
|
||||
</Typography>
|
||||
</ButtonBase>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
580
src/components/VideoPlayer/VideoControls.tsx
Normal file
580
src/components/VideoPlayer/VideoControls.tsx
Normal file
@@ -0,0 +1,580 @@
|
||||
import {
|
||||
alpha,
|
||||
Box,
|
||||
ButtonBase,
|
||||
Divider,
|
||||
IconButton,
|
||||
Popper,
|
||||
Slider,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
export const fontSizeExSmall = "60%";
|
||||
export const fontSizeSmall = "80%";
|
||||
import {
|
||||
Fullscreen,
|
||||
Pause,
|
||||
PlayArrow,
|
||||
Refresh,
|
||||
VolumeOff,
|
||||
VolumeUp,
|
||||
} from "@mui/icons-material";
|
||||
import { formatTime } from "../../utils/time.js";
|
||||
import { CustomFontTooltip } from "./CustomFontTooltip.js";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import SlowMotionVideoIcon from "@mui/icons-material/SlowMotionVideo";
|
||||
const buttonPaddingBig = "6px";
|
||||
const buttonPaddingSmall = "4px";
|
||||
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
|
||||
import ArrowBackIosIcon from "@mui/icons-material/ArrowBackIos";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import { useLibTranslation } from "../../hooks/useLibTranslation.js";
|
||||
|
||||
export const PlayButton = ({ togglePlay, isPlaying, isScreenSmall }: any) => {
|
||||
const { t } = useLibTranslation();
|
||||
|
||||
return (
|
||||
<CustomFontTooltip title={t("video.play_pause")} placement="bottom" arrow>
|
||||
<IconButton
|
||||
sx={{
|
||||
color: "white",
|
||||
padding: isScreenSmall ? buttonPaddingSmall : buttonPaddingBig,
|
||||
}}
|
||||
onClick={() => togglePlay()}
|
||||
>
|
||||
{isPlaying ? <Pause /> : <PlayArrow />}
|
||||
</IconButton>
|
||||
</CustomFontTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const ReloadButton = ({ reloadVideo, isScreenSmall }: any) => {
|
||||
const { t } = useLibTranslation();
|
||||
|
||||
return (
|
||||
<CustomFontTooltip title={t("video.reload_video")} placement="bottom" arrow>
|
||||
<IconButton
|
||||
sx={{
|
||||
color: "white",
|
||||
padding: isScreenSmall ? buttonPaddingSmall : buttonPaddingBig,
|
||||
}}
|
||||
onClick={reloadVideo}
|
||||
>
|
||||
<Refresh />
|
||||
</IconButton>
|
||||
</CustomFontTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProgressSlider = ({
|
||||
progress,
|
||||
setLocalProgress,
|
||||
duration,
|
||||
playerRef,
|
||||
resetHideTimeout,
|
||||
isVideoPlayerSmall,
|
||||
isOnTimeline,
|
||||
railColor,
|
||||
thumbColor,
|
||||
trackColor
|
||||
}: any) => {
|
||||
const sliderRef = useRef(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [sliderValue, setSliderValue] = useState(0); // local slider value
|
||||
const [hoverX, setHoverX] = useState<number | null>(null);
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
|
||||
const [showDuration, setShowDuration] = useState(0);
|
||||
|
||||
const showTimeFunc = (val: number, clientX: number) => {
|
||||
const slider = sliderRef.current;
|
||||
if (!slider) return;
|
||||
const percent = val / duration;
|
||||
const time = Math.min(Math.max(0, percent * duration), duration);
|
||||
|
||||
setHoverX(clientX);
|
||||
setShowDuration(time);
|
||||
|
||||
resetHideTimeout();
|
||||
};
|
||||
const onProgressChange = (e: any, value: number | number[]) => {
|
||||
const clientX =
|
||||
"touches" in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX;
|
||||
if (clientX && resetHideTimeout) {
|
||||
showTimeFunc(value as number, clientX);
|
||||
}
|
||||
setIsDragging(true);
|
||||
setSliderValue(value as number);
|
||||
};
|
||||
const onChangeCommitted = (e: any, value: number | number[]) => {
|
||||
if (!playerRef.current) return;
|
||||
setSliderValue(value as number);
|
||||
playerRef.current?.currentTime(value as number);
|
||||
setIsDragging(false);
|
||||
setLocalProgress(value);
|
||||
handleMouseLeave();
|
||||
};
|
||||
|
||||
const THUMBNAIL_DEBOUNCE = 500;
|
||||
const THUMBNAIL_MIN_DIFF = 10;
|
||||
|
||||
const lastRequestedTimeRef = useRef<number | null>(null);
|
||||
const debounceTimeoutRef = useRef<any>(null);
|
||||
const previousBlobUrlRef = useRef<string | null>(null);
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
const slider = sliderRef.current;
|
||||
if (!slider) return;
|
||||
|
||||
const rect = slider.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percent = x / rect.width;
|
||||
const time = Math.min(Math.max(0, percent * duration), duration);
|
||||
|
||||
// Position anchor element at the correct spot
|
||||
if (hoverAnchorRef.current) {
|
||||
hoverAnchorRef.current.style.left = `${x}px`;
|
||||
}
|
||||
|
||||
setHoverX(e.clientX); // optional – can be removed unless used elsewhere
|
||||
setShowDuration(time);
|
||||
|
||||
if (debounceTimeoutRef.current) clearTimeout(debounceTimeoutRef.current);
|
||||
};
|
||||
const handleMouseLeave = () => {
|
||||
lastRequestedTimeRef.current = null;
|
||||
setThumbnailUrl(null);
|
||||
setHoverX(null);
|
||||
if (debounceTimeoutRef.current) clearTimeout(debounceTimeoutRef.current);
|
||||
|
||||
if (previousBlobUrlRef.current) {
|
||||
URL.revokeObjectURL(previousBlobUrlRef.current);
|
||||
previousBlobUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previousBlobUrlRef.current) {
|
||||
URL.revokeObjectURL(previousBlobUrlRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hoverAnchorRef = useRef<HTMLDivElement | null>(null);
|
||||
if (hoverX) {
|
||||
}
|
||||
|
||||
const handleClickCapture = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOnTimeline) return;
|
||||
if (hoverX) {
|
||||
isOnTimeline.current = true;
|
||||
} else {
|
||||
isOnTimeline.current = false;
|
||||
}
|
||||
}, [hoverX]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="relative"
|
||||
sx={{
|
||||
width: "100%",
|
||||
padding: isVideoPlayerSmall ? "0px" : "0px 10px",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
ref={hoverAnchorRef}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
width: "1px",
|
||||
height: "1px",
|
||||
pointerEvents: "none",
|
||||
transform: "translateX(-50%)", // center popper on the anchor
|
||||
}}
|
||||
/>
|
||||
<Slider
|
||||
ref={sliderRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClickCapture={handleClickCapture}
|
||||
value={isDragging ? sliderValue : progress} // use local state if dragging
|
||||
onChange={onProgressChange}
|
||||
onChangeCommitted={onChangeCommitted}
|
||||
min={0}
|
||||
max={duration || 100}
|
||||
step={0.1}
|
||||
sx={{
|
||||
color: "#00abff",
|
||||
padding: "0px",
|
||||
borderRadius: "0px",
|
||||
height: "0px",
|
||||
"@media (pointer: coarse)": { padding: "0px" },
|
||||
"& .MuiSlider-thumb": {
|
||||
backgroundColor: thumbColor || "red",
|
||||
width: "14px",
|
||||
height: "14px",
|
||||
},
|
||||
"& .MuiSlider-thumb::after": {
|
||||
width: "14px",
|
||||
height: "14px",
|
||||
backgroundColor: thumbColor || "red",
|
||||
},
|
||||
"& .MuiSlider-rail": {
|
||||
opacity: 0.5,
|
||||
height: "6px",
|
||||
backgroundColor: railColor || "#73859f80",
|
||||
},
|
||||
"& .MuiSlider-track": {
|
||||
height: "6px",
|
||||
border: "0px",
|
||||
backgroundColor: trackColor || "red",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{hoverX !== null && (
|
||||
<Popper
|
||||
open
|
||||
anchorEl={hoverAnchorRef.current}
|
||||
placement="top"
|
||||
disablePortal
|
||||
modifiers={[{ name: "offset", options: { offset: [10, 0] } }]}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
bgcolor: alpha("#181818", 0.75),
|
||||
padding: "5px",
|
||||
borderRadius: "5px",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: "0.8rom",
|
||||
textShadow: "0 0 5px rgba(0, 0, 0, 0.7)",
|
||||
fontFamily: "sans-serif",
|
||||
}}
|
||||
>
|
||||
{formatTime(showDuration)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Popper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const VideoTime = ({ progress, isScreenSmall, duration }: any) => {
|
||||
const { t } = useLibTranslation();
|
||||
|
||||
return (
|
||||
<CustomFontTooltip
|
||||
title={t("video.seek_video")}
|
||||
placement="bottom"
|
||||
arrow
|
||||
disableHoverListener={isScreenSmall}
|
||||
disableFocusListener={isScreenSmall}
|
||||
disableTouchListener={isScreenSmall}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: isScreenSmall ? fontSizeExSmall : fontSizeSmall,
|
||||
color: "white",
|
||||
visibility: typeof duration !== "number" ? "hidden" : "visible",
|
||||
whiteSpace: "nowrap",
|
||||
fontFamily: "sans-serif",
|
||||
}}
|
||||
>
|
||||
{typeof duration === "number" ? formatTime(progress) : ""}
|
||||
{" / "}
|
||||
{typeof duration === "number" ? formatTime(duration) : ""}
|
||||
</Typography>
|
||||
</CustomFontTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const VolumeButton = ({ isMuted, toggleMute }: any) => {
|
||||
const { t } = useLibTranslation();
|
||||
|
||||
return (
|
||||
<CustomFontTooltip title={t("video.toggle_mute")} placement="bottom" arrow>
|
||||
<IconButton
|
||||
sx={{
|
||||
color: "white",
|
||||
}}
|
||||
onClick={toggleMute}
|
||||
>
|
||||
{isMuted ? <VolumeOff /> : <VolumeUp />}
|
||||
</IconButton>
|
||||
</CustomFontTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const VolumeSlider = ({ width, volume, onVolumeChange }: any) => {
|
||||
let color = "";
|
||||
if (volume <= 0.5) color = "green";
|
||||
else if (volume <= 0.75) color = "yellow";
|
||||
else color = "red";
|
||||
|
||||
return (
|
||||
<Slider
|
||||
value={volume}
|
||||
onChange={onVolumeChange}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
sx={{
|
||||
width,
|
||||
marginRight: "10px",
|
||||
color,
|
||||
"& .MuiSlider-thumb": {
|
||||
backgroundColor: "#fff",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
},
|
||||
"& .MuiSlider-thumb::after": { width: "16px", height: "16px" },
|
||||
"& .MuiSlider-rail": { opacity: 0.5, height: "6px" },
|
||||
"& .MuiSlider-track": { height: "6px", border: "0px" },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const VolumeControl = ({
|
||||
sliderWidth,
|
||||
onVolumeChange,
|
||||
volume,
|
||||
isMuted,
|
||||
toggleMute,
|
||||
}: any) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{ display: "flex", gap: "5px", alignItems: "center", width: "100%" }}
|
||||
>
|
||||
<VolumeButton isMuted={isMuted} toggleMute={toggleMute} />
|
||||
<VolumeSlider
|
||||
width={sliderWidth}
|
||||
onVolumeChange={onVolumeChange}
|
||||
volume={volume}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const speeds = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 2.5, 3];
|
||||
|
||||
export const PlaybackRate = ({
|
||||
playbackRate,
|
||||
increaseSpeed,
|
||||
isScreenSmall,
|
||||
onSelect,
|
||||
openPlaybackMenu,
|
||||
}: any) => {
|
||||
const { t } = useLibTranslation();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const btnRef = useRef(null);
|
||||
const theme = useTheme();
|
||||
const onBack = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomFontTooltip
|
||||
title={t("video.video_speed")}
|
||||
placement="bottom"
|
||||
arrow
|
||||
>
|
||||
<IconButton
|
||||
ref={btnRef}
|
||||
sx={{
|
||||
color: "white",
|
||||
fontSize: fontSizeSmall,
|
||||
padding: isScreenSmall ? buttonPaddingSmall : buttonPaddingBig,
|
||||
}}
|
||||
onClick={() => openPlaybackMenu()}
|
||||
>
|
||||
<SlowMotionVideoIcon />
|
||||
</IconButton>
|
||||
</CustomFontTooltip>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const FullscreenButton = ({ toggleFullscreen, isScreenSmall }: any) => {
|
||||
const { t } = useLibTranslation();
|
||||
|
||||
return (
|
||||
<CustomFontTooltip
|
||||
title={t("video.toggle_fullscreen")}
|
||||
placement="bottom"
|
||||
arrow
|
||||
>
|
||||
<IconButton
|
||||
sx={{
|
||||
color: "white",
|
||||
padding: isScreenSmall ? buttonPaddingSmall : buttonPaddingBig,
|
||||
}}
|
||||
onClick={() => toggleFullscreen()}
|
||||
>
|
||||
<Fullscreen />
|
||||
</IconButton>
|
||||
</CustomFontTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
interface PlayBackMenuProps {
|
||||
close: () => void;
|
||||
isOpen: boolean;
|
||||
onSelect: (speed: number) => void;
|
||||
playbackRate: number;
|
||||
isFromDrawer: boolean;
|
||||
}
|
||||
export const PlayBackMenu = ({
|
||||
close,
|
||||
onSelect,
|
||||
isOpen,
|
||||
playbackRate,
|
||||
isFromDrawer,
|
||||
}: PlayBackMenuProps) => {
|
||||
const { t } = useLibTranslation();
|
||||
|
||||
const theme = useTheme();
|
||||
const ref = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
ref?.current?.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleBlur = (e: React.FocusEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget) && !isFromDrawer) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 9, // one layer below MUI drawer
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Box
|
||||
ref={ref}
|
||||
tabIndex={-1}
|
||||
onBlur={handleBlur}
|
||||
bgcolor={alpha("#181818", 0.98)}
|
||||
sx={{
|
||||
position: isFromDrawer ? "relative" : "absolute",
|
||||
bottom: isFromDrawer ? "relative" : 60,
|
||||
right: isFromDrawer ? "relative" : 5,
|
||||
color: "white",
|
||||
opacity: 0.9,
|
||||
borderRadius: 2,
|
||||
boxShadow: isFromDrawer ? "relative" : 5,
|
||||
p: 1,
|
||||
minWidth: 225,
|
||||
height: 300,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
padding: "5px 0px 10px 0px",
|
||||
display: "flex",
|
||||
gap: "10px",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<ButtonBase onClick={close}>
|
||||
<ArrowBackIosIcon
|
||||
sx={{
|
||||
fontSize: "1.15em",
|
||||
}}
|
||||
/>
|
||||
</ButtonBase>
|
||||
<ButtonBase>
|
||||
<Typography
|
||||
onClick={close}
|
||||
sx={{
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
{t("video.playback_speed")}
|
||||
</Typography>
|
||||
</ButtonBase>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flexGrow: 1,
|
||||
overflow: "auto",
|
||||
"::-webkit-scrollbar-track": {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
|
||||
"::-webkit-scrollbar": {
|
||||
width: "16px",
|
||||
height: "10px",
|
||||
},
|
||||
|
||||
"::-webkit-scrollbar-thumb": {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
borderRadius: "8px",
|
||||
backgroundClip: "content-box",
|
||||
border: "4px solid transparent",
|
||||
transition: "0.3s background-color",
|
||||
},
|
||||
|
||||
"::-webkit-scrollbar-thumb:hover": {
|
||||
backgroundColor: theme.palette.primary.dark,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{speeds?.map((speed) => {
|
||||
const isSelected = speed === playbackRate;
|
||||
return (
|
||||
<ButtonBase
|
||||
disabled={isSelected}
|
||||
key={speed}
|
||||
onClick={(e) => {
|
||||
onSelect(speed);
|
||||
close();
|
||||
}}
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
},
|
||||
width: "100%",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Typography>{speed}</Typography>
|
||||
{isSelected ? <CheckIcon /> : <ArrowForwardIosIcon />}
|
||||
</ButtonBase>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
188
src/components/VideoPlayer/VideoControlsBar.tsx
Normal file
188
src/components/VideoPlayer/VideoControlsBar.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { Box, IconButton } from "@mui/material";
|
||||
import { ControlsContainer } from "./VideoPlayer-styles";
|
||||
import {
|
||||
FullscreenButton,
|
||||
PlaybackRate,
|
||||
PlayButton,
|
||||
ProgressSlider,
|
||||
ReloadButton,
|
||||
VideoTime,
|
||||
VolumeControl,
|
||||
} from "./VideoControls";
|
||||
import SubtitlesIcon from "@mui/icons-material/Subtitles";
|
||||
import { CustomFontTooltip } from "./CustomFontTooltip";
|
||||
import { CSSProperties, RefObject } from "react";
|
||||
import { useLibTranslation } from "../../hooks/useLibTranslation";
|
||||
import i18n from "../../i18n/i18n";
|
||||
interface VideoControlsBarProps {
|
||||
canPlay: boolean;
|
||||
isScreenSmall: boolean;
|
||||
controlsHeight?: string;
|
||||
progress: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
togglePlay: () => void;
|
||||
reloadVideo: () => void;
|
||||
volume: number;
|
||||
onVolumeChange: (_: any, val: number) => void;
|
||||
toggleFullscreen: () => void;
|
||||
showControls: boolean;
|
||||
showControlsFullScreen: boolean;
|
||||
isFullScreen: boolean;
|
||||
playerRef: any;
|
||||
increaseSpeed: () => void;
|
||||
decreaseSpeed: () => void;
|
||||
playbackRate: number;
|
||||
openSubtitleManager: () => void;
|
||||
subtitleBtnRef: any;
|
||||
onSelectPlaybackRate: (rate: number) => void;
|
||||
isMuted: boolean;
|
||||
toggleMute: () => void;
|
||||
openPlaybackMenu: () => void;
|
||||
togglePictureInPicture: () => void;
|
||||
isVideoPlayerSmall: boolean;
|
||||
setLocalProgress: (val: number) => void;
|
||||
isOnTimeline: RefObject<boolean>;
|
||||
styling?: {
|
||||
progressSlider?: {
|
||||
thumbColor?: CSSProperties["color"];
|
||||
railColor?: CSSProperties["color"];
|
||||
trackColor?: CSSProperties["color"];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const VideoControlsBar = ({
|
||||
subtitleBtnRef,
|
||||
setLocalProgress,
|
||||
showControls,
|
||||
playbackRate,
|
||||
increaseSpeed,
|
||||
decreaseSpeed,
|
||||
isFullScreen,
|
||||
showControlsFullScreen,
|
||||
reloadVideo,
|
||||
onVolumeChange,
|
||||
volume,
|
||||
isPlaying,
|
||||
canPlay,
|
||||
isScreenSmall,
|
||||
controlsHeight,
|
||||
playerRef,
|
||||
duration,
|
||||
progress,
|
||||
togglePlay,
|
||||
toggleFullscreen,
|
||||
openSubtitleManager,
|
||||
onSelectPlaybackRate,
|
||||
isMuted,
|
||||
toggleMute,
|
||||
openPlaybackMenu,
|
||||
togglePictureInPicture,
|
||||
isVideoPlayerSmall,
|
||||
isOnTimeline,
|
||||
styling
|
||||
}: VideoControlsBarProps) => {
|
||||
const { t } = useLibTranslation();
|
||||
|
||||
const showMobileControls = isScreenSmall && canPlay;
|
||||
|
||||
const controlGroupSX = {
|
||||
display: "flex",
|
||||
gap: "5px",
|
||||
alignItems: "center",
|
||||
height: controlsHeight,
|
||||
};
|
||||
|
||||
let additionalStyles: React.CSSProperties = {};
|
||||
if (isFullScreen && showControlsFullScreen) {
|
||||
additionalStyles = {
|
||||
opacity: 1,
|
||||
position: "fixed",
|
||||
bottom: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<ControlsContainer
|
||||
style={{
|
||||
padding: "0px",
|
||||
opacity: showControls ? 1 : 0,
|
||||
pointerEvents: showControls ? "auto" : "none",
|
||||
transition: "opacity 0.4s ease-in-out",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{showMobileControls ? null : canPlay ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<ProgressSlider
|
||||
setLocalProgress={setLocalProgress}
|
||||
playerRef={playerRef}
|
||||
progress={progress}
|
||||
duration={duration}
|
||||
isOnTimeline={isOnTimeline}
|
||||
thumbColor={styling?.progressSlider?.thumbColor}
|
||||
railColor={styling?.progressSlider?.railColor}
|
||||
trackColor={styling?.progressSlider?.trackColor}
|
||||
/>
|
||||
{!isVideoPlayerSmall && (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
<Box sx={controlGroupSX}>
|
||||
<PlayButton isPlaying={isPlaying} togglePlay={togglePlay} />
|
||||
<ReloadButton reloadVideo={reloadVideo} />
|
||||
|
||||
<VolumeControl
|
||||
onVolumeChange={onVolumeChange}
|
||||
volume={volume}
|
||||
sliderWidth={"100px"}
|
||||
isMuted={isMuted}
|
||||
toggleMute={toggleMute}
|
||||
/>
|
||||
<VideoTime progress={progress} duration={duration} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ ...controlGroupSX, marginLeft: "auto" }}>
|
||||
<PlaybackRate
|
||||
openPlaybackMenu={openPlaybackMenu}
|
||||
onSelect={onSelectPlaybackRate}
|
||||
playbackRate={playbackRate}
|
||||
increaseSpeed={increaseSpeed}
|
||||
decreaseSpeed={decreaseSpeed}
|
||||
/>
|
||||
<CustomFontTooltip
|
||||
title={t("subtitle.subtitles")}
|
||||
placement="bottom"
|
||||
arrow
|
||||
>
|
||||
<IconButton
|
||||
ref={subtitleBtnRef}
|
||||
onClick={openSubtitleManager}
|
||||
>
|
||||
<SubtitlesIcon
|
||||
sx={{
|
||||
color: "white",
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
</CustomFontTooltip>
|
||||
|
||||
<FullscreenButton toggleFullscreen={toggleFullscreen} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : null}
|
||||
</ControlsContainer>
|
||||
);
|
||||
};
|
||||
47
src/components/VideoPlayer/VideoPlayer-styles.ts
Normal file
47
src/components/VideoPlayer/VideoPlayer-styles.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { styled, Theme } from "@mui/system";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
export const VideoContainer = styled(Box, {
|
||||
shouldForwardProp: (prop) => prop !== 'isVideoPlayerSmall',
|
||||
})<{ isVideoPlayerSmall?: boolean }>(({ theme, isVideoPlayerSmall }) => ({
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
borderRadius: isVideoPlayerSmall ? '0px' : '12px',
|
||||
overflow: 'hidden',
|
||||
"&:focus": { outline: "none" },
|
||||
}));
|
||||
|
||||
export const VideoElement = styled("video")(({ theme }) => ({
|
||||
|
||||
background: "rgb(33, 33, 33)",
|
||||
|
||||
"&:focus": {
|
||||
outline: "none !important",
|
||||
boxShadow: "none !important",
|
||||
},
|
||||
"&:focus-visible": {
|
||||
outline: "none !important",
|
||||
boxShadow: "none !important",
|
||||
},
|
||||
"&::-webkit-media-controls": {
|
||||
display: "none !important",
|
||||
}
|
||||
}));
|
||||
|
||||
//1075 x 604
|
||||
export const ControlsContainer = styled(Box)`
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-image: linear-gradient(0deg,#000,#0000);
|
||||
`;
|
||||
939
src/components/VideoPlayer/VideoPlayer.tsx
Normal file
939
src/components/VideoPlayer/VideoPlayer.tsx
Normal file
@@ -0,0 +1,939 @@
|
||||
import {
|
||||
CSSProperties,
|
||||
RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { QortalGetMetadata } from "../../types/interfaces/resources";
|
||||
import { VideoContainer, VideoElement } from "./VideoPlayer-styles";
|
||||
import { useVideoPlayerHotKeys } from "./useVideoPlayerHotKeys";
|
||||
import {
|
||||
useIsPlaying,
|
||||
useProgressStore,
|
||||
useVideoStore,
|
||||
} from "../../state/video";
|
||||
import { useVideoPlayerController } from "./useVideoPlayerController";
|
||||
import { LoadingVideo } from "./LoadingVideo";
|
||||
import { VideoControlsBar } from "./VideoControlsBar";
|
||||
import videojs from "video.js";
|
||||
import "video.js/dist/video-js.css";
|
||||
|
||||
import { SubtitleManager, SubtitlePublishedData } from "./SubtitleManager";
|
||||
import { base64ToBlobUrl } from "../../utils/base64";
|
||||
import convert from "srt-webvtt";
|
||||
import { TimelineActionsComponent } from "./TimelineActionsComponent";
|
||||
import { PlayBackMenu } from "./VideoControls";
|
||||
import { useGlobalPlayerStore } from "../../state/pip";
|
||||
import { alpha, ClickAwayListener, Drawer } from "@mui/material";
|
||||
import { MobileControls } from "./MobileControls";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
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" });
|
||||
// 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";
|
||||
|
||||
export type TimelineAction =
|
||||
| {
|
||||
type: "SEEK";
|
||||
time: number;
|
||||
duration: number;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
seekToTime: number; // ✅ Required for SEEK
|
||||
placement?: "TOP-RIGHT" | "TOP-LEFT" | "BOTTOM-LEFT" | "BOTTOM-RIGHT";
|
||||
}
|
||||
| {
|
||||
type: "CUSTOM";
|
||||
time: number;
|
||||
duration: number;
|
||||
label: string;
|
||||
onClick: () => void; // ✅ Required for CUSTOM
|
||||
placement?: "TOP-RIGHT" | "TOP-LEFT" | "BOTTOM-LEFT" | "BOTTOM-RIGHT";
|
||||
};
|
||||
export interface VideoPlayerProps {
|
||||
qortalVideoResource: QortalGetMetadata;
|
||||
videoRef: any;
|
||||
retryAttempts?: number;
|
||||
poster?: string;
|
||||
autoPlay?: boolean;
|
||||
onEnded?: (e: React.SyntheticEvent<HTMLVideoElement, Event>) => void;
|
||||
onPlay?: () => void;
|
||||
onPause?: () => void;
|
||||
timelineActions?: TimelineAction[];
|
||||
playerRef: any;
|
||||
locationRef: RefObject<string | null>;
|
||||
videoLocationRef: RefObject<string | null>;
|
||||
filename?: string;
|
||||
path?: string;
|
||||
styling?: {
|
||||
progressSlider?: {
|
||||
thumbColor?: CSSProperties["color"];
|
||||
railColor?: CSSProperties["color"];
|
||||
trackColor?: CSSProperties["color"];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const videoStyles = {
|
||||
videoContainer: {},
|
||||
video: {},
|
||||
};
|
||||
|
||||
async function getVideoMimeTypeFromUrl(
|
||||
qortalVideoResource: any
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const metadataResponse = await fetch(
|
||||
`/arbitrary/metadata/${qortalVideoResource.service}/${qortalVideoResource.name}/${qortalVideoResource.identifier}`
|
||||
);
|
||||
const metadataData = await metadataResponse.json();
|
||||
return metadataData?.mimeType || null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export const isTouchDevice =
|
||||
"ontouchstart" in window || navigator.maxTouchPoints > 0;
|
||||
|
||||
export const VideoPlayer = ({
|
||||
videoRef,
|
||||
playerRef,
|
||||
qortalVideoResource,
|
||||
retryAttempts,
|
||||
poster,
|
||||
autoPlay,
|
||||
onEnded,
|
||||
onPlay: onPlayParent,
|
||||
onPause: onPauseParent,
|
||||
timelineActions,
|
||||
locationRef,
|
||||
videoLocationRef,
|
||||
path,
|
||||
filename,
|
||||
styling,
|
||||
}: VideoPlayerProps) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [videoObjectFit] = useState<StretchVideoType>("contain");
|
||||
const { isPlaying, setIsPlaying } = useIsPlaying();
|
||||
const isOnTimeline = useRef(false);
|
||||
const [width, setWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
setWidth(entry.contentRect.width);
|
||||
});
|
||||
if (containerRef.current) observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
const { volume, setVolume, setPlaybackRate, playbackRate } = useVideoStore(
|
||||
(state) => ({
|
||||
volume: state.playbackSettings.volume,
|
||||
setVolume: state.setVolume,
|
||||
setPlaybackRate: state.setPlaybackRate,
|
||||
playbackRate: state.playbackSettings.playbackRate,
|
||||
})
|
||||
);
|
||||
// const playerRef = useRef<Player | null>(null);
|
||||
const [drawerOpenSubtitles, setDrawerOpenSubtitles] = useState(false);
|
||||
const [drawerOpenPlayback, setDrawerOpenPlayback] = useState(false);
|
||||
const [showControlsMobile2, setShowControlsMobile] = useState(false);
|
||||
const [isPlayerInitialized, setIsPlayerInitialized] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const { setProgress } = useProgressStore();
|
||||
const [localProgress, setLocalProgress] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showControls, setShowControls] = useState(false);
|
||||
const [isOpenSubtitleManage, setIsOpenSubtitleManage] = useState(false);
|
||||
const subtitleBtnRef = useRef(null);
|
||||
const [currentSubTrack, setCurrentSubTrack] = useState<null | string>(null);
|
||||
const location = useLocation();
|
||||
|
||||
const [isOpenPlaybackMenu, setIsOpenPlaybackmenu] = useState(false);
|
||||
const isVideoPlayerSmall = width < 600 || isTouchDevice;
|
||||
const {
|
||||
reloadVideo,
|
||||
togglePlay,
|
||||
onVolumeChange,
|
||||
increaseSpeed,
|
||||
decreaseSpeed,
|
||||
toggleMute,
|
||||
isFullscreen,
|
||||
toggleObjectFit,
|
||||
controlsHeight,
|
||||
setProgressRelative,
|
||||
changeVolume,
|
||||
isReady,
|
||||
resourceUrl,
|
||||
startPlay,
|
||||
setProgressAbsolute,
|
||||
status,
|
||||
percentLoaded,
|
||||
showControlsFullScreen,
|
||||
onSelectPlaybackRate,
|
||||
seekTo,
|
||||
togglePictureInPicture,
|
||||
downloadResource,
|
||||
isStatusWrong
|
||||
} = useVideoPlayerController({
|
||||
autoPlay,
|
||||
playerRef,
|
||||
qortalVideoResource,
|
||||
retryAttempts,
|
||||
isMuted,
|
||||
videoRef,
|
||||
filename,
|
||||
path,
|
||||
});
|
||||
|
||||
const showControlsMobile =
|
||||
(showControlsMobile2 || !isPlaying) && isVideoPlayerSmall;
|
||||
|
||||
useEffect(() => {
|
||||
if (location) {
|
||||
locationRef.current = location?.pathname;
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
const { getProgress } = useProgressStore();
|
||||
|
||||
const enterFullscreen = useCallback(async () => {
|
||||
const ref = containerRef?.current as HTMLElement | null;
|
||||
if (!ref || document.fullscreenElement) return;
|
||||
|
||||
try {
|
||||
// Wait for fullscreen to activate
|
||||
if (ref.requestFullscreen) {
|
||||
await ref.requestFullscreen();
|
||||
} else if ((ref as any).webkitRequestFullscreen) {
|
||||
await (ref as any).webkitRequestFullscreen(); // Safari fallback
|
||||
}
|
||||
|
||||
if (
|
||||
typeof screen.orientation !== "undefined" &&
|
||||
"lock" in screen.orientation &&
|
||||
typeof screen.orientation.lock === "function"
|
||||
) {
|
||||
try {
|
||||
await (screen.orientation as any).lock("landscape");
|
||||
} catch (err) {
|
||||
console.warn("Orientation lock failed:", err);
|
||||
}
|
||||
}
|
||||
await qortalRequest({
|
||||
action: "SCREEN_ORIENTATION",
|
||||
mode: "landscape",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to enter fullscreen or lock orientation:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const exitFullscreen = useCallback(async () => {
|
||||
try {
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
|
||||
if (
|
||||
typeof screen.orientation !== "undefined" &&
|
||||
"lock" in screen.orientation &&
|
||||
typeof screen.orientation.lock === "function"
|
||||
) {
|
||||
try {
|
||||
// Attempt to reset by locking to 'portrait' or 'any' (if supported)
|
||||
await screen.orientation.lock("portrait"); // or 'any' if supported
|
||||
} catch (err) {
|
||||
console.warn("Orientation lock failed:", err);
|
||||
}
|
||||
}
|
||||
await qortalRequest({
|
||||
action: "SCREEN_ORIENTATION",
|
||||
mode: "portrait",
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("Error exiting fullscreen or unlocking orientation:", err);
|
||||
}
|
||||
}, [isFullscreen]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
setShowControls(false);
|
||||
setShowControlsMobile(false);
|
||||
isFullscreen ? exitFullscreen() : enterFullscreen();
|
||||
}, [isFullscreen]);
|
||||
|
||||
const hotkeyHandlers = useMemo(
|
||||
() => ({
|
||||
reloadVideo,
|
||||
togglePlay,
|
||||
setProgressRelative,
|
||||
toggleObjectFit,
|
||||
increaseSpeed,
|
||||
decreaseSpeed,
|
||||
changeVolume,
|
||||
toggleMute,
|
||||
setProgressAbsolute,
|
||||
toggleFullscreen,
|
||||
}),
|
||||
[
|
||||
reloadVideo,
|
||||
togglePlay,
|
||||
setProgressRelative,
|
||||
toggleObjectFit,
|
||||
increaseSpeed,
|
||||
decreaseSpeed,
|
||||
changeVolume,
|
||||
toggleMute,
|
||||
setProgressAbsolute,
|
||||
toggleFullscreen,
|
||||
]
|
||||
);
|
||||
|
||||
const closeSubtitleManager = useCallback(() => {
|
||||
setIsOpenSubtitleManage(false);
|
||||
setDrawerOpenSubtitles(false);
|
||||
}, []);
|
||||
const openSubtitleManager = useCallback(() => {
|
||||
if (isVideoPlayerSmall) {
|
||||
setDrawerOpenSubtitles(true);
|
||||
return;
|
||||
}
|
||||
setIsOpenSubtitleManage(true);
|
||||
}, [isVideoPlayerSmall]);
|
||||
|
||||
const videoLocation = useMemo(() => {
|
||||
if (!qortalVideoResource) return null;
|
||||
return `${qortalVideoResource.service}-${qortalVideoResource.name}-${qortalVideoResource.identifier}`;
|
||||
}, [qortalVideoResource]);
|
||||
useEffect(() => {
|
||||
videoLocationRef.current = videoLocation;
|
||||
}, [videoLocation]);
|
||||
useVideoPlayerHotKeys(hotkeyHandlers);
|
||||
|
||||
const updateProgress = useCallback(() => {
|
||||
if (!isPlaying || !isPlayerInitialized) return;
|
||||
const player = playerRef?.current;
|
||||
if (!player || typeof player?.currentTime !== "function") return;
|
||||
|
||||
const currentTime = player.currentTime();
|
||||
if (typeof currentTime === "number" && videoLocation && currentTime > 0.1) {
|
||||
setProgress(videoLocation, currentTime);
|
||||
setLocalProgress(currentTime);
|
||||
}
|
||||
}, [videoLocation, isPlaying, isPlayerInitialized]);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoLocation) {
|
||||
const vidId = useGlobalPlayerStore.getState().videoId;
|
||||
if (vidId === videoLocation) {
|
||||
togglePlay();
|
||||
}
|
||||
}
|
||||
}, [videoLocation]);
|
||||
|
||||
const onPlay = useCallback(() => {
|
||||
setIsPlaying(true);
|
||||
if (onPlayParent) {
|
||||
onPlayParent();
|
||||
}
|
||||
}, [setIsPlaying, onPlayParent]);
|
||||
|
||||
const onPause = useCallback(() => {
|
||||
setIsPlaying(false);
|
||||
if (onPauseParent) {
|
||||
onPauseParent();
|
||||
}
|
||||
}, [setIsPlaying]);
|
||||
const onVolumeChangeHandler = useCallback(
|
||||
(e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
|
||||
try {
|
||||
const video = e.currentTarget;
|
||||
setVolume(video.volume);
|
||||
setIsMuted(video.muted);
|
||||
} catch (error) {
|
||||
console.error("onVolumeChangeHandler", onVolumeChangeHandler);
|
||||
}
|
||||
},
|
||||
[setIsMuted, setVolume]
|
||||
);
|
||||
|
||||
const videoStylesContainer = useMemo(() => {
|
||||
return {
|
||||
cursor: "auto",
|
||||
...videoStyles?.videoContainer,
|
||||
};
|
||||
}, [showControls, isVideoPlayerSmall]);
|
||||
|
||||
const videoStylesVideo = useMemo(() => {
|
||||
return {
|
||||
...videoStyles?.video,
|
||||
objectFit: videoObjectFit,
|
||||
backgroundColor: "#000000",
|
||||
height: isFullscreen ? "calc(100vh - 40px)" : "100%",
|
||||
width: "100%",
|
||||
cursor: showControls ? "default" : "none",
|
||||
};
|
||||
}, [videoObjectFit, isFullscreen, showControls]);
|
||||
|
||||
const handleEnded = useCallback(
|
||||
(e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
|
||||
if (onEnded) {
|
||||
onEnded(e);
|
||||
}
|
||||
},
|
||||
[onEnded]
|
||||
);
|
||||
|
||||
const handleCanPlay = useCallback(() => {
|
||||
setIsLoading(false);
|
||||
}, [setIsLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlayerInitialized) return;
|
||||
const player = playerRef.current;
|
||||
if (!player || typeof player.on !== "function") return;
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
const duration = player.duration?.();
|
||||
|
||||
if (typeof duration === "number" && !isNaN(duration)) {
|
||||
setDuration(duration);
|
||||
}
|
||||
};
|
||||
|
||||
player.on("loadedmetadata", handleLoadedMetadata);
|
||||
|
||||
if (player?.readyState() >= 1) {
|
||||
handleLoadedMetadata();
|
||||
}
|
||||
return () => {
|
||||
player.off("loadedmetadata", handleLoadedMetadata);
|
||||
};
|
||||
}, [isPlayerInitialized]);
|
||||
|
||||
const hideTimeout = useRef<any>(null);
|
||||
|
||||
const resetHideTimer = () => {
|
||||
if (isVideoPlayerSmall) return;
|
||||
setShowControls(true);
|
||||
if (hideTimeout.current) clearTimeout(hideTimeout.current);
|
||||
hideTimeout.current = setTimeout(() => {
|
||||
if (isOnTimeline?.current) return;
|
||||
setShowControls(false);
|
||||
}, 2500); // 3s of inactivity
|
||||
};
|
||||
|
||||
const handleMouseMove = () => {
|
||||
if (isVideoPlayerSmall) return;
|
||||
resetHideTimer();
|
||||
};
|
||||
|
||||
const closePlaybackMenu = useCallback(() => {
|
||||
setIsOpenPlaybackmenu(false);
|
||||
setDrawerOpenPlayback(false);
|
||||
}, []);
|
||||
const openPlaybackMenu = useCallback(() => {
|
||||
if (isVideoPlayerSmall) {
|
||||
setDrawerOpenPlayback(true);
|
||||
return;
|
||||
}
|
||||
setIsOpenPlaybackmenu(true);
|
||||
}, [isVideoPlayerSmall]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVideoPlayerSmall) return;
|
||||
resetHideTimer(); // initial show
|
||||
return () => {
|
||||
if (hideTimeout.current) clearTimeout(hideTimeout.current);
|
||||
};
|
||||
}, [isVideoPlayerSmall]);
|
||||
|
||||
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) => {
|
||||
if (subtitle === null) {
|
||||
setCurrentSubTrack(null);
|
||||
if (previousSubtitleUrlRef.current) {
|
||||
URL.revokeObjectURL(previousSubtitleUrlRef.current);
|
||||
previousSubtitleUrlRef.current = null;
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
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: subtitle.language,
|
||||
label: subtitle.language,
|
||||
default: true,
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
await new Promise((res) => {
|
||||
setTimeout(() => {
|
||||
res(null);
|
||||
}, 1000);
|
||||
});
|
||||
const tracksInfo = playerRef.current?.textTracks();
|
||||
if (!tracksInfo) return;
|
||||
|
||||
const tracks = Array.from(
|
||||
{ length: (tracksInfo as any).length },
|
||||
(_, i) => (tracksInfo as any)[i]
|
||||
);
|
||||
for (const track of tracks) {
|
||||
if (track.kind === "subtitles") {
|
||||
track.mode = "showing"; // force display
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setShowControls(false);
|
||||
if (hideTimeout.current) clearTimeout(hideTimeout.current);
|
||||
}, [setShowControls]);
|
||||
|
||||
const videoLocactionStringified = useMemo(() => {
|
||||
return JSON.stringify(qortalVideoResource);
|
||||
}, [qortalVideoResource]);
|
||||
|
||||
const savedVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (startPlay) {
|
||||
useGlobalPlayerStore.getState().reset();
|
||||
}
|
||||
}, [startPlay]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Save the video element while it's still mounted
|
||||
const video = videoRef as any;
|
||||
if (video.current) {
|
||||
savedVideoRef.current = video.current;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resourceUrl || !isReady || !videoLocactionStringified || !startPlay)
|
||||
return;
|
||||
|
||||
const resource = JSON.parse(videoLocactionStringified);
|
||||
|
||||
try {
|
||||
const setupPlayer = async () => {
|
||||
const type = await getVideoMimeTypeFromUrl(resource);
|
||||
|
||||
const options = {
|
||||
autoplay: true,
|
||||
controls: false,
|
||||
responsive: true,
|
||||
// fluid: true,
|
||||
poster: startPlay ? "" : poster,
|
||||
// aspectRatio: "16:9",
|
||||
sources: [
|
||||
{
|
||||
src: resourceUrl,
|
||||
type: type || "video/mp4", // fallback
|
||||
},
|
||||
],
|
||||
};
|
||||
const ref = videoRef as any;
|
||||
if (!ref.current) return;
|
||||
|
||||
if (!playerRef.current && ref.current) {
|
||||
playerRef.current = videojs(ref.current, options, () => {
|
||||
setIsPlayerInitialized(true);
|
||||
ref.current.tabIndex = -1; // Prevents focus entirely
|
||||
ref.current.style.outline = "none"; // Backup
|
||||
playerRef.current?.poster("");
|
||||
playerRef.current?.playbackRate(playbackRate);
|
||||
playerRef.current?.volume(volume);
|
||||
const key = `${resource.service}-${resource.name}-${resource.identifier}`;
|
||||
if (key) {
|
||||
const savedProgress = getProgress(key);
|
||||
if (typeof savedProgress === "number") {
|
||||
playerRef.current?.currentTime(savedProgress);
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
);
|
||||
for (const track of tracks) {
|
||||
if (track.kind === "subtitles" || track.kind === "captions") {
|
||||
if (track.mode === "showing") {
|
||||
activeTrack = track;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeTrack) {
|
||||
setCurrentSubTrack(activeTrack.language || activeTrack.srclang);
|
||||
} else {
|
||||
setCurrentSubTrack(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial check in case one is auto-enabled
|
||||
checkActiveSubtitle();
|
||||
|
||||
// Use Video.js event system
|
||||
tracksInfo?.on("change", checkActiveSubtitle);
|
||||
});
|
||||
playerRef.current?.on("error", () => {
|
||||
const error = playerRef.current?.error();
|
||||
console.error("Video.js playback error:", error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
setupPlayer();
|
||||
} catch (error) {
|
||||
console.error("useEffect start player", error);
|
||||
}
|
||||
}, [isReady, resourceUrl, startPlay, poster, videoLocactionStringified]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlayerInitialized) return;
|
||||
const player = playerRef?.current;
|
||||
if (!player) return;
|
||||
|
||||
const handleRateChange = () => {
|
||||
const newRate = player?.playbackRate();
|
||||
if (newRate) {
|
||||
setPlaybackRate(newRate); // or any other state/action
|
||||
}
|
||||
};
|
||||
|
||||
player.on("ratechange", handleRateChange);
|
||||
|
||||
return () => {
|
||||
player.off("ratechange", handleRateChange);
|
||||
};
|
||||
}, [isPlayerInitialized]);
|
||||
const hideTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const resetHideTimeout = () => {
|
||||
setShowControlsMobile(true);
|
||||
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
|
||||
hideTimeoutRef.current = setTimeout(() => {
|
||||
setShowControlsMobile(false);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleInteraction = () => resetHideTimeout();
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener("touchstart", handleInteraction);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("touchstart", handleInteraction);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClickVideoElement = useCallback(() => {
|
||||
if (isVideoPlayerSmall) {
|
||||
resetHideTimeout();
|
||||
return;
|
||||
}
|
||||
togglePlay();
|
||||
}, [isVideoPlayerSmall, togglePlay]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<VideoContainer
|
||||
tabIndex={0}
|
||||
style={videoStylesContainer}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
ref={containerRef}
|
||||
isVideoPlayerSmall={isVideoPlayerSmall}
|
||||
>
|
||||
<LoadingVideo
|
||||
togglePlay={togglePlay}
|
||||
isReady={isReady}
|
||||
status={status}
|
||||
percentLoaded={percentLoaded}
|
||||
isLoading={isLoading}
|
||||
startPlay={startPlay}
|
||||
downloadResource={downloadResource}
|
||||
isStatusWrong={isStatusWrong}
|
||||
/>
|
||||
<VideoElement
|
||||
ref={videoRef}
|
||||
tabIndex={-1}
|
||||
className="video-js"
|
||||
src={isReady && startPlay ? resourceUrl || undefined : undefined}
|
||||
poster={poster}
|
||||
onTimeUpdate={updateProgress}
|
||||
autoPlay={autoPlay}
|
||||
onClick={handleClickVideoElement}
|
||||
onEnded={handleEnded}
|
||||
onCanPlay={handleCanPlay}
|
||||
preload="metadata"
|
||||
style={videoStylesVideo}
|
||||
onPlay={onPlay}
|
||||
onPause={onPause}
|
||||
onVolumeChange={onVolumeChangeHandler}
|
||||
controls={false}
|
||||
/>
|
||||
{!isVideoPlayerSmall && (
|
||||
<PlayBackMenu
|
||||
isFromDrawer={false}
|
||||
close={closePlaybackMenu}
|
||||
isOpen={isOpenPlaybackMenu}
|
||||
onSelect={onSelectPlaybackRate}
|
||||
playbackRate={playbackRate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isReady && showControls && (
|
||||
<VideoControlsBar
|
||||
isVideoPlayerSmall={isVideoPlayerSmall}
|
||||
subtitleBtnRef={subtitleBtnRef}
|
||||
playbackRate={playbackRate}
|
||||
increaseSpeed={hotkeyHandlers.increaseSpeed}
|
||||
decreaseSpeed={hotkeyHandlers.decreaseSpeed}
|
||||
playerRef={playerRef}
|
||||
isFullScreen={isFullscreen}
|
||||
showControlsFullScreen={showControlsFullScreen}
|
||||
showControls={showControls}
|
||||
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}
|
||||
onSelectPlaybackRate={onSelectPlaybackRate}
|
||||
isMuted={isMuted}
|
||||
toggleMute={toggleMute}
|
||||
openPlaybackMenu={openPlaybackMenu}
|
||||
togglePictureInPicture={togglePictureInPicture}
|
||||
setLocalProgress={setLocalProgress}
|
||||
isOnTimeline={isOnTimeline}
|
||||
styling={styling}
|
||||
/>
|
||||
)}
|
||||
{timelineActions && Array.isArray(timelineActions) && (
|
||||
<TimelineActionsComponent
|
||||
seekTo={seekTo}
|
||||
containerRef={containerRef}
|
||||
progress={localProgress}
|
||||
timelineActions={timelineActions}
|
||||
isVideoPlayerSmall={isVideoPlayerSmall}
|
||||
/>
|
||||
)}
|
||||
{showControlsMobile && isReady && (
|
||||
<MobileControls
|
||||
setLocalProgress={setLocalProgress}
|
||||
setProgressRelative={setProgressRelative}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
openPlaybackMenu={openPlaybackMenu}
|
||||
openSubtitleManager={openSubtitleManager}
|
||||
togglePlay={togglePlay}
|
||||
isPlaying={isPlaying}
|
||||
setShowControlsMobile={setShowControlsMobile}
|
||||
resetHideTimeout={resetHideTimeout}
|
||||
duration={duration}
|
||||
progress={localProgress}
|
||||
playerRef={playerRef}
|
||||
showControlsMobile={showControlsMobile}
|
||||
styling={styling}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isVideoPlayerSmall && (
|
||||
<SubtitleManager
|
||||
subtitleBtnRef={subtitleBtnRef}
|
||||
close={closeSubtitleManager}
|
||||
open={isOpenSubtitleManage}
|
||||
qortalMetadata={qortalVideoResource}
|
||||
onSelect={onSelectSubtitle}
|
||||
currentSubTrack={currentSubTrack}
|
||||
setDrawerOpenSubtitles={setDrawerOpenSubtitles}
|
||||
isFromDrawer={false}
|
||||
exitFullscreen={exitFullscreen}
|
||||
/>
|
||||
)}
|
||||
<ClickAwayListener onClickAway={() => setDrawerOpenSubtitles(false)}>
|
||||
<Drawer
|
||||
variant="persistent"
|
||||
anchor="bottom"
|
||||
open={drawerOpenSubtitles && isVideoPlayerSmall}
|
||||
sx={{}}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
backgroundColor: alpha("#181818", 0.98),
|
||||
borderRadius: 2,
|
||||
width: "90%",
|
||||
margin: "0 auto",
|
||||
p: 1,
|
||||
backgroundImage: "none",
|
||||
mb: 1,
|
||||
position: "absolute",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SubtitleManager
|
||||
subtitleBtnRef={subtitleBtnRef}
|
||||
close={closeSubtitleManager}
|
||||
open={true}
|
||||
qortalMetadata={qortalVideoResource}
|
||||
onSelect={onSelectSubtitle}
|
||||
currentSubTrack={currentSubTrack}
|
||||
setDrawerOpenSubtitles={setDrawerOpenSubtitles}
|
||||
isFromDrawer={true}
|
||||
exitFullscreen={exitFullscreen}
|
||||
/>
|
||||
</Drawer>
|
||||
</ClickAwayListener>
|
||||
<ClickAwayListener onClickAway={() => {
|
||||
setDrawerOpenPlayback(false)
|
||||
}}>
|
||||
<Drawer
|
||||
variant="persistent"
|
||||
anchor="bottom"
|
||||
open={drawerOpenPlayback && isVideoPlayerSmall}
|
||||
sx={{}}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
backgroundColor: alpha("#181818", 0.98),
|
||||
borderRadius: 2,
|
||||
width: "90%",
|
||||
margin: "0 auto",
|
||||
p: 1,
|
||||
backgroundImage: "none",
|
||||
mb: 1,
|
||||
position: "absolute",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PlayBackMenu
|
||||
isFromDrawer
|
||||
close={closePlaybackMenu}
|
||||
isOpen={true}
|
||||
onSelect={onSelectPlaybackRate}
|
||||
playbackRate={playbackRate}
|
||||
/>
|
||||
</Drawer>
|
||||
</ClickAwayListener>
|
||||
</VideoContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
122
src/components/VideoPlayer/VideoPlayerParent.tsx
Normal file
122
src/components/VideoPlayer/VideoPlayerParent.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React, { CSSProperties, useContext, useEffect, useRef } from "react";
|
||||
import { TimelineAction, VideoPlayer, VideoPlayerProps } from "./VideoPlayer";
|
||||
import { useGlobalPlayerStore } from "../../state/pip";
|
||||
import Player from "video.js/dist/types/player";
|
||||
import { useIsPlaying } from "../../state/video";
|
||||
import { QortalGetMetadata } from "../../types/interfaces/resources";
|
||||
import { GlobalContext } from "../../context/GlobalProvider";
|
||||
|
||||
export interface VideoPlayerParentProps {
|
||||
qortalVideoResource: QortalGetMetadata;
|
||||
videoRef: any;
|
||||
retryAttempts?: number;
|
||||
poster?: string;
|
||||
autoPlay?: boolean;
|
||||
onEnded?: (e: React.SyntheticEvent<HTMLVideoElement, Event>) => void;
|
||||
onPlay?: () => void;
|
||||
onPause?: () => void;
|
||||
timelineActions?: TimelineAction[];
|
||||
path?: string;
|
||||
filename?: string;
|
||||
styling?: {
|
||||
progressSlider?: {
|
||||
thumbColor?: CSSProperties["color"];
|
||||
railColor?: CSSProperties["color"];
|
||||
trackColor?: CSSProperties["color"];
|
||||
};
|
||||
};
|
||||
}
|
||||
export const VideoPlayerParent = ({
|
||||
videoRef,
|
||||
qortalVideoResource,
|
||||
retryAttempts,
|
||||
poster,
|
||||
autoPlay,
|
||||
onEnded,
|
||||
onPlay,
|
||||
onPause,
|
||||
timelineActions,
|
||||
path,
|
||||
filename,
|
||||
styling,
|
||||
}: VideoPlayerParentProps) => {
|
||||
const context = useContext(GlobalContext);
|
||||
const playerRef = useRef<Player | null>(null);
|
||||
const locationRef = useRef<string | null>(null);
|
||||
const videoLocationRef = useRef<null | string>(null);
|
||||
const { isPlaying, setIsPlaying } = useIsPlaying();
|
||||
const isPlayingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
isPlayingRef.current = isPlaying;
|
||||
}, [isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const player = playerRef.current;
|
||||
|
||||
const isPlaying = isPlayingRef.current;
|
||||
const currentSrc = player?.currentSrc();
|
||||
|
||||
if (
|
||||
context?.enableGlobalVideoFeature &&
|
||||
currentSrc &&
|
||||
isPlaying &&
|
||||
videoLocationRef.current
|
||||
) {
|
||||
const current = player?.currentTime?.();
|
||||
const currentSource = player?.currentType();
|
||||
|
||||
useGlobalPlayerStore.getState().setVideoState({
|
||||
videoSrc: currentSrc,
|
||||
currentTime: current ?? 0,
|
||||
isPlaying: true,
|
||||
mode: "floating",
|
||||
videoId: videoLocationRef.current,
|
||||
location: locationRef.current || "",
|
||||
type: currentSource || "video/mp4",
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [context?.enableGlobalVideoFeature]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const player = playerRef.current;
|
||||
|
||||
setIsPlaying(false);
|
||||
|
||||
if (player && typeof player.dispose === "function") {
|
||||
try {
|
||||
player.dispose();
|
||||
} catch (err) {
|
||||
console.error("Error disposing Video.js player:", err);
|
||||
}
|
||||
playerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
qortalVideoResource?.service,
|
||||
qortalVideoResource?.name,
|
||||
qortalVideoResource?.identifier,
|
||||
]);
|
||||
|
||||
return (
|
||||
<VideoPlayer
|
||||
key={`${qortalVideoResource.service}-${qortalVideoResource.name}-${qortalVideoResource.identifier}`}
|
||||
videoRef={videoRef}
|
||||
qortalVideoResource={qortalVideoResource}
|
||||
retryAttempts={retryAttempts}
|
||||
poster={poster}
|
||||
autoPlay={autoPlay}
|
||||
onEnded={onEnded}
|
||||
timelineActions={timelineActions}
|
||||
playerRef={playerRef}
|
||||
locationRef={locationRef}
|
||||
videoLocationRef={videoLocationRef}
|
||||
filename={filename}
|
||||
path={path}
|
||||
onPlay={onPlay}
|
||||
onPause={onPause}
|
||||
styling={styling}
|
||||
/>
|
||||
);
|
||||
};
|
||||
309
src/components/VideoPlayer/useVideoPlayerController.tsx
Normal file
309
src/components/VideoPlayer/useVideoPlayerController.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useVideoStore } from "../../state/video";
|
||||
import { QortalGetMetadata } from "../../types/interfaces/resources";
|
||||
import { useResourceStatus } from "../../hooks/useResourceStatus";
|
||||
import useIdleTimeout from "../../common/useIdleTimeout";
|
||||
import { useGlobalPlayerStore } from "../../state/pip";
|
||||
|
||||
const controlsHeight = "42px";
|
||||
const minSpeed = 0.25;
|
||||
const maxSpeed = 4.0;
|
||||
const speedChange = 0.25;
|
||||
|
||||
interface UseVideoControls {
|
||||
playerRef: any;
|
||||
autoPlay?: boolean;
|
||||
qortalVideoResource: QortalGetMetadata;
|
||||
retryAttempts?: number;
|
||||
isMuted: boolean;
|
||||
videoRef: any;
|
||||
filename?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
export const useVideoPlayerController = (props: UseVideoControls) => {
|
||||
const {
|
||||
autoPlay,
|
||||
videoRef,
|
||||
playerRef,
|
||||
qortalVideoResource,
|
||||
retryAttempts,
|
||||
isMuted,
|
||||
filename = "",
|
||||
path = ""
|
||||
} = props;
|
||||
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showControlsFullScreen, setShowControlsFullScreen] = useState(false);
|
||||
const [videoObjectFit, setVideoObjectFit] = useState<"contain" | "fill">(
|
||||
"contain"
|
||||
);
|
||||
const [startPlay, setStartPlay] = useState(false);
|
||||
const [startedFetch, setStartedFetch] = useState(false);
|
||||
const startedFetchRef = useRef(false);
|
||||
const { playbackSettings, setPlaybackRate } = useVideoStore();
|
||||
|
||||
const { isReady, resourceUrl, status, localChunkCount, totalChunkCount , percentLoaded, downloadResource } =
|
||||
useResourceStatus({
|
||||
resource: !startedFetch ? null : qortalVideoResource,
|
||||
retryAttempts,
|
||||
filename,
|
||||
path,
|
||||
isGlobal: true
|
||||
});
|
||||
|
||||
const idleTime = 5000; // Time in milliseconds
|
||||
useIdleTimeout({
|
||||
onIdle: () => setShowControlsFullScreen(false),
|
||||
onActive: () => setShowControlsFullScreen(true),
|
||||
idleTime,
|
||||
});
|
||||
|
||||
const updatePlaybackRate = useCallback(
|
||||
(newSpeed: number) => {
|
||||
try {
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
if (newSpeed > maxSpeed || newSpeed < minSpeed) newSpeed = minSpeed;
|
||||
|
||||
const clampedSpeed = Math.min(Math.max(newSpeed, minSpeed), maxSpeed);
|
||||
player.playbackRate(clampedSpeed); // ✅ Video.js API
|
||||
} catch (error) {
|
||||
console.error("updatePlaybackRate", error);
|
||||
}
|
||||
},
|
||||
[setPlaybackRate, minSpeed, maxSpeed]
|
||||
);
|
||||
|
||||
const increaseSpeed = useCallback(
|
||||
(wrapOverflow = true) => {
|
||||
try {
|
||||
const changedSpeed = playbackSettings.playbackRate + speedChange;
|
||||
const newSpeed = wrapOverflow
|
||||
? changedSpeed
|
||||
: Math.min(changedSpeed, maxSpeed);
|
||||
updatePlaybackRate(newSpeed);
|
||||
} catch (error) {
|
||||
console.error("increaseSpeed", increaseSpeed);
|
||||
}
|
||||
},
|
||||
[updatePlaybackRate, playbackSettings.playbackRate]
|
||||
);
|
||||
|
||||
const decreaseSpeed = useCallback(() => {
|
||||
updatePlaybackRate(playbackSettings.playbackRate - speedChange);
|
||||
}, [updatePlaybackRate, playbackSettings.playbackRate]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||
return () =>
|
||||
document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
const onVolumeChange = useCallback((_: any, value: number | number[]) => {
|
||||
try {
|
||||
const newVolume = value as number;
|
||||
const ref = playerRef as any;
|
||||
if (!ref.current) return;
|
||||
if (ref.current) {
|
||||
playerRef.current?.volume(newVolume);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("onVolumeChange", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
try {
|
||||
const ref = playerRef as any;
|
||||
if (!ref.current) return;
|
||||
|
||||
ref.current?.muted(!isMuted);
|
||||
} catch (error) {
|
||||
console.error("toggleMute", toggleMute);
|
||||
}
|
||||
}, [isMuted]);
|
||||
|
||||
const changeVolume = useCallback((delta: number) => {
|
||||
try {
|
||||
const player = playerRef.current;
|
||||
if (!player || typeof player.volume !== "function") return;
|
||||
|
||||
const currentVolume = player.volume(); // Get current volume (0–1)
|
||||
let newVolume = Math.max(0, Math.min(currentVolume + delta, 1));
|
||||
newVolume = +newVolume.toFixed(2); // Round to 2 decimal places
|
||||
|
||||
player.volume(newVolume); // Set new volume
|
||||
player.muted(false); // Ensure it's unmuted
|
||||
} catch (error) {
|
||||
console.error("changeVolume", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setProgressRelative = useCallback((seconds: number) => {
|
||||
try {
|
||||
const player = playerRef.current;
|
||||
if (
|
||||
!player ||
|
||||
typeof player.currentTime !== "function" ||
|
||||
typeof player.duration !== "function"
|
||||
)
|
||||
return;
|
||||
|
||||
const current = player.currentTime();
|
||||
const duration = player.duration() || 100;
|
||||
const newTime = Math.max(0, Math.min(current + seconds, duration));
|
||||
|
||||
player.currentTime(newTime);
|
||||
} catch (error) {
|
||||
console.error("setProgressRelative", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setProgressAbsolute = useCallback((percent: number) => {
|
||||
try {
|
||||
const player = playerRef.current;
|
||||
if (
|
||||
!player ||
|
||||
typeof player.duration !== "function" ||
|
||||
typeof player.currentTime !== "function"
|
||||
)
|
||||
return;
|
||||
|
||||
const duration = player.duration();
|
||||
const clampedPercent = Math.min(100, Math.max(0, percent));
|
||||
const finalTime = (duration * clampedPercent) / 100;
|
||||
|
||||
player.currentTime(finalTime);
|
||||
} catch (error) {
|
||||
console.error("setProgressAbsolute", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const seekTo = useCallback((time: number) => {
|
||||
try {
|
||||
const player = playerRef.current;
|
||||
if (
|
||||
!player ||
|
||||
typeof player.duration !== "function" ||
|
||||
typeof player.currentTime !== "function"
|
||||
)
|
||||
return;
|
||||
|
||||
player.currentTime(time);
|
||||
} catch (error) {
|
||||
console.error("setProgressAbsolute", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleObjectFit = useCallback(() => {
|
||||
setVideoObjectFit(videoObjectFit === "contain" ? "fill" : "contain");
|
||||
}, [setVideoObjectFit]);
|
||||
|
||||
const togglePlay = useCallback(async () => {
|
||||
try {
|
||||
if (!startedFetchRef.current) {
|
||||
setStartedFetch(true);
|
||||
startedFetchRef.current = true;
|
||||
setStartPlay(true);
|
||||
return;
|
||||
}
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
if (isReady) {
|
||||
if (player.paused()) {
|
||||
try {
|
||||
await player.play();
|
||||
} catch (err) {
|
||||
console.warn("Play failed:", err);
|
||||
}
|
||||
} else {
|
||||
player.pause();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("togglePlay", error);
|
||||
}
|
||||
}, [setStartedFetch, isReady]);
|
||||
|
||||
const reloadVideo = useCallback(async () => {
|
||||
try {
|
||||
const player = playerRef.current;
|
||||
if (!player || !isReady || !resourceUrl) return;
|
||||
|
||||
const currentTime = player.currentTime();
|
||||
|
||||
player.src({ src: resourceUrl, type: "video/mp4" }); // Adjust type if needed
|
||||
player.load();
|
||||
|
||||
player.ready(() => {
|
||||
player.currentTime(currentTime);
|
||||
player.play().catch((err: any) => {
|
||||
console.warn("Playback failed after reload:", err);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [isReady, resourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoPlay) togglePlay();
|
||||
}, [autoPlay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady) {
|
||||
togglePlay();
|
||||
}
|
||||
}, [togglePlay, isReady]);
|
||||
|
||||
const togglePictureInPicture = async () => {
|
||||
if (!videoRef.current) return;
|
||||
const player = playerRef.current;
|
||||
if (
|
||||
!player ||
|
||||
typeof player.currentTime !== "function" ||
|
||||
typeof player.duration !== "function"
|
||||
)
|
||||
return;
|
||||
|
||||
const current = player.currentTime();
|
||||
useGlobalPlayerStore.getState().setVideoState({
|
||||
videoSrc: videoRef.current.src,
|
||||
currentTime: current,
|
||||
isPlaying: true,
|
||||
mode: "floating", // or 'floating'
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
reloadVideo,
|
||||
togglePlay,
|
||||
onVolumeChange,
|
||||
increaseSpeed,
|
||||
decreaseSpeed,
|
||||
toggleMute,
|
||||
isFullscreen,
|
||||
toggleObjectFit,
|
||||
controlsHeight,
|
||||
setProgressRelative,
|
||||
changeVolume,
|
||||
setProgressAbsolute,
|
||||
startedFetch,
|
||||
isReady,
|
||||
resourceUrl,
|
||||
startPlay,
|
||||
status,
|
||||
percentLoaded,
|
||||
showControlsFullScreen,
|
||||
onSelectPlaybackRate: updatePlaybackRate,
|
||||
seekTo,
|
||||
togglePictureInPicture,
|
||||
downloadResource,
|
||||
isStatusWrong: !isNaN(totalChunkCount) && !isNaN(localChunkCount) && totalChunkCount === 2 && (totalChunkCount < localChunkCount)
|
||||
};
|
||||
};
|
||||
140
src/components/VideoPlayer/useVideoPlayerHotKeys.tsx
Normal file
140
src/components/VideoPlayer/useVideoPlayerHotKeys.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
|
||||
interface UseVideoControls {
|
||||
reloadVideo: () => void;
|
||||
togglePlay: () => void;
|
||||
setProgressRelative: (seconds: number) => void;
|
||||
toggleObjectFit: () => void;
|
||||
increaseSpeed: (wrapOverflow?: boolean) => void;
|
||||
decreaseSpeed: () => void;
|
||||
changeVolume: (delta: number) => void;
|
||||
toggleMute: () => void;
|
||||
setProgressAbsolute: (percent: number) => void;
|
||||
toggleFullscreen: ()=> void;
|
||||
}
|
||||
|
||||
export const useVideoPlayerHotKeys = (props: UseVideoControls) => {
|
||||
const {
|
||||
reloadVideo,
|
||||
togglePlay,
|
||||
setProgressRelative,
|
||||
toggleObjectFit,
|
||||
increaseSpeed,
|
||||
decreaseSpeed,
|
||||
changeVolume,
|
||||
toggleMute,
|
||||
setProgressAbsolute,
|
||||
toggleFullscreen
|
||||
} = props;
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const tag = target.tagName.toUpperCase();
|
||||
const role = target.getAttribute("role");
|
||||
const isTypingOrInteractive =
|
||||
["INPUT", "TEXTAREA", "SELECT", "BUTTON"].includes(tag) ||
|
||||
target.isContentEditable ||
|
||||
role === "button";
|
||||
|
||||
if (isTypingOrInteractive) return;
|
||||
e.preventDefault();
|
||||
const key = e.key;
|
||||
const mod = (s: number) => setProgressRelative(s);
|
||||
|
||||
switch (key) {
|
||||
case "o":
|
||||
toggleObjectFit();
|
||||
break;
|
||||
case "f":
|
||||
toggleFullscreen();
|
||||
break;
|
||||
case "+":
|
||||
case ">":
|
||||
increaseSpeed(false);
|
||||
break;
|
||||
case "-":
|
||||
case "<":
|
||||
decreaseSpeed();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
if (e.shiftKey) mod(-300);
|
||||
else if (e.ctrlKey) mod(-60);
|
||||
else if (e.altKey) mod(-10);
|
||||
else mod(-5);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
if (e.shiftKey) mod(300);
|
||||
else if (e.ctrlKey) mod(60);
|
||||
else if (e.altKey) mod(10);
|
||||
else mod(5);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
changeVolume(-0.05);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
changeVolume(0.05);
|
||||
break;
|
||||
case " ":
|
||||
e.preventDefault(); // prevent scrolling
|
||||
togglePlay();
|
||||
break;
|
||||
case "m":
|
||||
toggleMute();
|
||||
break;
|
||||
case "r":
|
||||
reloadVideo();
|
||||
break;
|
||||
case "0":
|
||||
setProgressAbsolute(0);
|
||||
break;
|
||||
case "1":
|
||||
setProgressAbsolute(10);
|
||||
break;
|
||||
case "2":
|
||||
setProgressAbsolute(20);
|
||||
break;
|
||||
case "3":
|
||||
setProgressAbsolute(30);
|
||||
break;
|
||||
case "4":
|
||||
setProgressAbsolute(40);
|
||||
break;
|
||||
case "5":
|
||||
setProgressAbsolute(50);
|
||||
break;
|
||||
case "6":
|
||||
setProgressAbsolute(60);
|
||||
break;
|
||||
case "7":
|
||||
setProgressAbsolute(70);
|
||||
break;
|
||||
case "8":
|
||||
setProgressAbsolute(80);
|
||||
break;
|
||||
case "9":
|
||||
setProgressAbsolute(90);
|
||||
break;
|
||||
}
|
||||
}, [
|
||||
reloadVideo,
|
||||
togglePlay,
|
||||
setProgressRelative,
|
||||
toggleObjectFit,
|
||||
|
||||
increaseSpeed,
|
||||
decreaseSpeed,
|
||||
changeVolume,
|
||||
toggleMute,
|
||||
setProgressAbsolute,
|
||||
toggleFullscreen
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
|
||||
return null;
|
||||
};
|
||||
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"
|
||||
@@ -1,5 +1,11 @@
|
||||
import React, { createContext, CSSProperties, useContext, useMemo } from "react";
|
||||
import { useAuth, UseAuthProps } from "../hooks/useAuth";
|
||||
import React, {
|
||||
createContext,
|
||||
CSSProperties,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useAuth, UseAuthProps } from "../hooks/useInitializeAuth";
|
||||
import { useResources } from "../hooks/useResources";
|
||||
import { useAppInfo } from "../hooks/useAppInfo";
|
||||
import { useIdentifiers } from "../hooks/useIdentifiers";
|
||||
@@ -7,22 +13,23 @@ import { Toaster } from "react-hot-toast";
|
||||
import { usePersistentStore } from "../hooks/usePersistentStore";
|
||||
import { IndexManager } from "../components/IndexManager/IndexManager";
|
||||
import { useIndexes } from "../hooks/useIndexes";
|
||||
|
||||
|
||||
|
||||
|
||||
import { useProgressStore } from "../state/video";
|
||||
import { GlobalPipPlayer } from "../hooks/useGlobalPipPlayer";
|
||||
import { MultiPublishDialog } from "../components/MultiPublish/MultiPublishDialog";
|
||||
import { useMultiplePublishStore } from "../state/multiplePublish";
|
||||
import { useIframe } from "../hooks/useIframe";
|
||||
|
||||
// ✅ Define Global Context Type
|
||||
interface GlobalContextType {
|
||||
auth: ReturnType<typeof useAuth>;
|
||||
lists: ReturnType<typeof useResources>;
|
||||
appInfo: ReturnType<typeof useAppInfo>;
|
||||
identifierOperations: ReturnType<typeof useIdentifiers>
|
||||
persistentOperations: ReturnType<typeof usePersistentStore>
|
||||
indexOperations: ReturnType<typeof useIndexes>
|
||||
auth: ReturnType<typeof useAuth>;
|
||||
lists: ReturnType<typeof useResources>;
|
||||
appInfo: ReturnType<typeof useAppInfo>;
|
||||
identifierOperations: ReturnType<typeof useIdentifiers>;
|
||||
persistentOperations: ReturnType<typeof usePersistentStore>;
|
||||
indexOperations: ReturnType<typeof useIndexes>;
|
||||
enableGlobalVideoFeature: boolean;
|
||||
}
|
||||
|
||||
|
||||
// ✅ Define Config Type for Hook Options
|
||||
interface GlobalProviderProps {
|
||||
children: React.ReactNode;
|
||||
@@ -30,43 +37,82 @@ interface GlobalProviderProps {
|
||||
/** Authentication settings. */
|
||||
auth?: UseAuthProps;
|
||||
appName: string;
|
||||
publicSalt: string
|
||||
publicSalt: string;
|
||||
enableGlobalVideoFeature?: boolean;
|
||||
};
|
||||
toastStyle?: CSSProperties
|
||||
|
||||
toastStyle?: CSSProperties;
|
||||
}
|
||||
|
||||
// ✅ Create Context with Proper Type
|
||||
const GlobalContext = createContext<GlobalContextType | null>(null);
|
||||
|
||||
|
||||
export const GlobalContext = createContext<GlobalContextType | null>(null);
|
||||
|
||||
// 🔹 Global Provider (Handles Multiple Hooks)
|
||||
export const GlobalProvider = ({ children, config, toastStyle = {} }: GlobalProviderProps) => {
|
||||
export const GlobalProvider = ({
|
||||
children,
|
||||
config,
|
||||
toastStyle = {},
|
||||
}: GlobalProviderProps) => {
|
||||
|
||||
useIframe()
|
||||
// ✅ Call hooks and pass in options dynamically
|
||||
const auth = useAuth(config?.auth || {});
|
||||
|
||||
const appInfo = useAppInfo(config.appName, config?.publicSalt)
|
||||
const lists = useResources()
|
||||
const identifierOperations = useIdentifiers(config.publicSalt, config.appName)
|
||||
const persistentOperations = usePersistentStore(config.publicSalt, config.appName)
|
||||
const indexOperations = useIndexes()
|
||||
const isPublishing = useMultiplePublishStore((s) => s.isPublishing);
|
||||
const appInfo = useAppInfo(config.appName, config?.publicSalt);
|
||||
const lists = useResources();
|
||||
const identifierOperations = useIdentifiers(
|
||||
config.publicSalt,
|
||||
config.appName
|
||||
);
|
||||
const persistentOperations = usePersistentStore(
|
||||
config.publicSalt,
|
||||
config.appName
|
||||
);
|
||||
const indexOperations = useIndexes();
|
||||
// ✅ Merge all hooks into a single `contextValue`
|
||||
const contextValue = useMemo(() => ({ auth, lists, appInfo, identifierOperations, persistentOperations, indexOperations }), [auth, lists, appInfo, identifierOperations, persistentOperations]);
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
auth,
|
||||
lists,
|
||||
appInfo,
|
||||
identifierOperations,
|
||||
persistentOperations,
|
||||
indexOperations,
|
||||
enableGlobalVideoFeature: config?.enableGlobalVideoFeature || false,
|
||||
}),
|
||||
[
|
||||
auth,
|
||||
lists,
|
||||
appInfo,
|
||||
identifierOperations,
|
||||
persistentOperations,
|
||||
config?.enableGlobalVideoFeature,
|
||||
]
|
||||
);
|
||||
const { clearOldProgress } = useProgressStore();
|
||||
|
||||
useEffect(() => {
|
||||
clearOldProgress();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<GlobalContext.Provider value={contextValue}>
|
||||
<Toaster
|
||||
|
||||
{config?.enableGlobalVideoFeature && <GlobalPipPlayer />}
|
||||
|
||||
{isPublishing && <MultiPublishDialog />}
|
||||
<Toaster
|
||||
position="top-center"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: toastStyle
|
||||
style: toastStyle,
|
||||
}}
|
||||
containerStyle={{zIndex: 999999}}
|
||||
containerStyle={{ zIndex: 999999 }}
|
||||
/>
|
||||
<IndexManager username={auth?.name} />
|
||||
|
||||
<IndexManager username={auth?.name} />
|
||||
|
||||
{children}
|
||||
|
||||
</GlobalContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AddForeignServerQortalRequest, AddListItemsQortalRequest, BuyNameQortalRequest, CancelSellNameQortalRequest, CancelTradeSellOrderQortalRequest, CreatePollQortalRequest, CreateTradeBuyOrderQortalRequest, CreateTradeSellOrderQortalRequest, DecryptDataQortalRequest, DecryptDataWithSharingKeyQortalRequest, DecryptQortalGroupDataQortalRequest, DeleteHostedDataQortalRequest, DeleteListItemQortalRequest, EncryptDataQortalRequest, EncryptDataWithSharingKeyQortalRequest, EncryptQortalGroupDataQortalRequest, FetchQdnResourceQortalRequest, GetAccountDataQortalRequest, GetAccountNamesQortalRequest, GetBalanceQortalRequest, GetCrosschainServerInfoQortalRequest, GetDaySummaryQortalRequest, GetForeignFeeQortalRequest, GetHostedDataQortalRequest, GetListItemsQortalRequest, GetNameDataQortalRequest, GetPriceQortalRequest, GetQdnResourceMetadataQortalRequest, GetQdnResourcePropertiesQortalRequest, GetQdnResourceStatusQortalRequest, GetQdnResourceUrlQortalRequest, GetServerConnectionHistoryQortalRequest, GetTxActivitySummaryQortalRequest, GetUserAccountQortalRequest, GetUserWalletInfoQortalRequest, GetUserWalletQortalRequest, GetWalletBalanceQortalRequest, LinkToQdnResourceQortalRequest, ListQdnResourcesQortalRequest, PublishMultipleQdnResourcesQortalRequest, PublishQdnResourceQortalRequest, RegisterNameQortalRequest, RemoveForeignServerQortalRequest, SearchNamesQortalRequest, SearchQdnResourcesQortalRequest, SellNameQortalRequest, SendCoinQortalRequest, SetCurrentForeignServerQortalRequest, UpdateForeignFeeQortalRequest, UpdateNameQortalRequest, VoteOnPollQortalRequest, SendChatMessageQortalRequest, SearchChatMessagesQortalRequest, JoinGroupQortalRequest, AddGroupAdminQortalRequest, UpdateGroupQortalRequest, ListGroupsQortalRequest, CreateGroupQortalRequest, RemoveGroupAdminQortalRequest, BanFromGroupQortalRequest, CancelGroupBanQortalRequest, KickFromGroupQortalRequest, InviteToGroupQortalRequest, CancelGroupInviteQortalRequest, LeaveGroupQortalRequest, DeployAtQortalRequest, GetAtQortalRequest, GetAtDataQortalRequest, ListAtsQortalRequest, FetchBlockQortalRequest, FetchBlockRangeQortalRequest, SearchTransactionsQortalRequest, IsUsingPublicNodeQortalRequest, AdminActionQortalRequest, OpenNewTabQortalRequest, ShowActionsQortalRequest, SignTransactionQortalRequest, CreateAndCopyEmbedLinkQortalRequest, TransferAssetQortalRequest, ShowPdfReaderQortalRequest, SaveFileQortalRequest, GetPrimaryNameQortalRequest, } from "./types/qortalRequests/interfaces"
|
||||
import { AddForeignServerQortalRequest, AddListItemsQortalRequest, BuyNameQortalRequest, CancelSellNameQortalRequest, CancelTradeSellOrderQortalRequest, CreatePollQortalRequest, CreateTradeBuyOrderQortalRequest, CreateTradeSellOrderQortalRequest, DecryptDataQortalRequest, DecryptDataWithSharingKeyQortalRequest, DecryptQortalGroupDataQortalRequest, DeleteHostedDataQortalRequest, DeleteListItemQortalRequest, EncryptDataQortalRequest, EncryptDataWithSharingKeyQortalRequest, EncryptQortalGroupDataQortalRequest, FetchQdnResourceQortalRequest, GetAccountDataQortalRequest, GetAccountNamesQortalRequest, GetBalanceQortalRequest, GetCrosschainServerInfoQortalRequest, GetDaySummaryQortalRequest, GetForeignFeeQortalRequest, GetHostedDataQortalRequest, GetListItemsQortalRequest, GetNameDataQortalRequest, GetPriceQortalRequest, GetQdnResourceMetadataQortalRequest, GetQdnResourcePropertiesQortalRequest, GetQdnResourceStatusQortalRequest, GetQdnResourceUrlQortalRequest, GetServerConnectionHistoryQortalRequest, GetTxActivitySummaryQortalRequest, GetUserAccountQortalRequest, GetUserWalletInfoQortalRequest, GetUserWalletQortalRequest, GetWalletBalanceQortalRequest, LinkToQdnResourceQortalRequest, ListQdnResourcesQortalRequest, PublishMultipleQdnResourcesQortalRequest, PublishQdnResourceQortalRequest, RegisterNameQortalRequest, RemoveForeignServerQortalRequest, SearchNamesQortalRequest, SearchQdnResourcesQortalRequest, SellNameQortalRequest, SendCoinQortalRequest, SetCurrentForeignServerQortalRequest, UpdateForeignFeeQortalRequest, UpdateNameQortalRequest, VoteOnPollQortalRequest, SendChatMessageQortalRequest, SearchChatMessagesQortalRequest, JoinGroupQortalRequest, AddGroupAdminQortalRequest, UpdateGroupQortalRequest, ListGroupsQortalRequest, CreateGroupQortalRequest, RemoveGroupAdminQortalRequest, BanFromGroupQortalRequest, CancelGroupBanQortalRequest, KickFromGroupQortalRequest, InviteToGroupQortalRequest, CancelGroupInviteQortalRequest, LeaveGroupQortalRequest, DeployAtQortalRequest, GetAtQortalRequest, GetAtDataQortalRequest, ListAtsQortalRequest, FetchBlockQortalRequest, FetchBlockRangeQortalRequest, SearchTransactionsQortalRequest, IsUsingPublicNodeQortalRequest, AdminActionQortalRequest, OpenNewTabQortalRequest, ShowActionsQortalRequest, SignTransactionQortalRequest, CreateAndCopyEmbedLinkQortalRequest, TransferAssetQortalRequest, ShowPdfReaderQortalRequest, SaveFileQortalRequest, GetPrimaryNameQortalRequest, ScreenOrientation, GetNodeStatusQortalRequest, GetNodeInfoQortalRequest, } from "./types/qortalRequests/interfaces"
|
||||
|
||||
|
||||
declare global {
|
||||
@@ -84,7 +84,7 @@ declare global {
|
||||
CreateAndCopyEmbedLinkQortalRequest |
|
||||
TransferAssetQortalRequest |
|
||||
ShowPdfReaderQortalRequest |
|
||||
SaveFileQortalRequest | GetPrimaryNameQortalRequest
|
||||
SaveFileQortalRequest | GetPrimaryNameQortalRequest | ScreenOrientation | GetNodeStatusQortalRequest | GetNodeInfoQortalRequest;
|
||||
|
||||
|
||||
function qortalRequest(options: QortalRequestOptions): Promise<any>
|
||||
|
||||
24
src/hooks/useAllResourceStatus.tsx
Normal file
24
src/hooks/useAllResourceStatus.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
import { usePublishStore } from '../state/publishes';
|
||||
import { Service } from '../types/interfaces/resources';
|
||||
|
||||
export const useAllResourceStatus = () =>
|
||||
usePublishStore((state) =>
|
||||
Object.entries(state.resourceStatus)
|
||||
.filter(([_, status]) => status !== null)
|
||||
.map(([id, status]) => {
|
||||
const parts = id.split('-');
|
||||
const service = parts[0] as Service;
|
||||
const name = parts[1] || '';
|
||||
const identifier = parts.length > 2 ? parts.slice(2).join('-') : '';
|
||||
const { path, filename, ...rest } = status!;
|
||||
|
||||
return {
|
||||
id,
|
||||
metadata: { service, name, identifier },
|
||||
status: rest,
|
||||
path,
|
||||
filename
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -1,157 +1,83 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useAuthStore } from "../state/auth";
|
||||
import { userAccountInfo } from "./useInitializeAuth";
|
||||
|
||||
// ✅ Define Types
|
||||
/**
|
||||
* Configuration for balance retrieval behavior.
|
||||
*/
|
||||
export type BalanceSetting =
|
||||
| {
|
||||
/** If `true`, the balance will be fetched only once when the app loads. */
|
||||
onlyOnMount: true;
|
||||
/** `interval` cannot be set when `onlyOnMount` is `true`. */
|
||||
interval?: never;
|
||||
}
|
||||
| {
|
||||
/** If `false` or omitted, balance will be updated periodically. */
|
||||
onlyOnMount?: false;
|
||||
/** The time interval (in milliseconds) for balance updates. */
|
||||
interval?: number;
|
||||
};
|
||||
|
||||
interface userAccountInfo {
|
||||
address: string;
|
||||
publicKey: string
|
||||
}
|
||||
export interface UseAuthProps {
|
||||
balanceSetting?: BalanceSetting;
|
||||
/** User will be prompted for authentication on start-up */
|
||||
authenticateOnMount?: boolean;
|
||||
userAccountInfo?: userAccountInfo | null
|
||||
}
|
||||
|
||||
export const useAuth = ({ balanceSetting, authenticateOnMount = true, userAccountInfo = null }: UseAuthProps) => {
|
||||
export const useAuth = () => {
|
||||
const address = useAuthStore((s) => s.address);
|
||||
const publicKey = useAuthStore((s) => s.publicKey);
|
||||
const name = useAuthStore((s) => s.name);
|
||||
const avatarUrl = useAuthStore((s) => s.avatarUrl);
|
||||
const balance = useAuthStore((s) => s.balance);
|
||||
const publicKey = useAuthStore((s) => s.publicKey);
|
||||
const name = useAuthStore((s) => s.name);
|
||||
const avatarUrl = useAuthStore((s) => s.avatarUrl);
|
||||
|
||||
const isLoadingUser = useAuthStore((s) => s.isLoadingUser);
|
||||
const isLoadingInitialBalance = useAuthStore((s) => s.isLoadingInitialBalance);
|
||||
const errorLoadingUser = useAuthStore((s) => s.errorLoadingUser);
|
||||
const isLoadingUser = useAuthStore((s) => s.isLoadingUser);
|
||||
const errorLoadingUser = useAuthStore((s) => s.errorLoadingUser);
|
||||
const setErrorLoadingUser = useAuthStore((s) => s.setErrorLoadingUser);
|
||||
const setIsLoadingUser = useAuthStore((s) => s.setIsLoadingUser);
|
||||
const setUser = useAuthStore((s) => s.setUser);
|
||||
const setName = useAuthStore((s) => s.setName);
|
||||
const authenticateUser = useCallback(
|
||||
async (userAccountInfo?: userAccountInfo) => {
|
||||
try {
|
||||
setErrorLoadingUser(null);
|
||||
setIsLoadingUser(true);
|
||||
|
||||
const setErrorLoadingUser = useAuthStore((s) => s.setErrorLoadingUser);
|
||||
const setIsLoadingUser = useAuthStore((s) => s.setIsLoadingUser);
|
||||
const setUser = useAuthStore((s) => s.setUser);
|
||||
const setBalance = useAuthStore((s) => s.setBalance);
|
||||
const account =
|
||||
userAccountInfo ||
|
||||
(await qortalRequest({
|
||||
action: "GET_USER_ACCOUNT",
|
||||
}));
|
||||
|
||||
|
||||
const balanceSetIntervalRef = useRef<null | ReturnType<typeof setInterval>>(null);
|
||||
|
||||
const authenticateUser = useCallback(async (userAccountInfo?: userAccountInfo) => {
|
||||
try {
|
||||
setErrorLoadingUser(null);
|
||||
setIsLoadingUser(true);
|
||||
|
||||
const account = userAccountInfo || await qortalRequest({
|
||||
action: "GET_USER_ACCOUNT",
|
||||
});
|
||||
|
||||
if (account?.address) {
|
||||
const nameData = await qortalRequest({
|
||||
action: "GET_PRIMARY_NAME",
|
||||
address: account.address,
|
||||
});
|
||||
setUser({ ...account, name: nameData || "" });
|
||||
if (account?.address) {
|
||||
const nameData = await qortalRequest({
|
||||
action: "GET_PRIMARY_NAME",
|
||||
address: account.address,
|
||||
});
|
||||
setUser({ ...account, name: nameData || "" });
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorLoadingUser(
|
||||
error instanceof Error ? error.message : "Unable to authenticate"
|
||||
);
|
||||
} finally {
|
||||
setIsLoadingUser(false);
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorLoadingUser(
|
||||
error instanceof Error ? error.message : "Unable to authenticate"
|
||||
);
|
||||
} finally {
|
||||
setIsLoadingUser(false);
|
||||
}
|
||||
}, [setErrorLoadingUser, setIsLoadingUser, setUser]);
|
||||
},
|
||||
[setErrorLoadingUser, setIsLoadingUser, setUser]
|
||||
);
|
||||
|
||||
const getBalance = useCallback(async (address: string): Promise<number> => {
|
||||
try {
|
||||
const response = await qortalRequest({
|
||||
action: "GET_BALANCE",
|
||||
address,
|
||||
});
|
||||
const userBalance = Number(response) || 0
|
||||
setBalance(userBalance);
|
||||
return userBalance
|
||||
} catch (error) {
|
||||
setBalance(0);
|
||||
return 0
|
||||
}
|
||||
}, [setBalance]);
|
||||
const switchName = useCallback(
|
||||
async (name: string) => {
|
||||
if (!name) throw new Error("No name provided");
|
||||
const response = await fetch(`/names/${name}`);
|
||||
if (!response?.ok) throw new Error("Error fetching name details");
|
||||
const nameInfo = await response.json();
|
||||
const currentAddress = useAuthStore.getState().address;
|
||||
|
||||
const balanceSetInterval = useCallback((address: string, interval: number) => {
|
||||
try {
|
||||
if (balanceSetIntervalRef.current) {
|
||||
clearInterval(balanceSetIntervalRef.current);
|
||||
}
|
||||
|
||||
let isCalling = false;
|
||||
balanceSetIntervalRef.current = setInterval(async () => {
|
||||
if (isCalling) return;
|
||||
isCalling = true;
|
||||
await getBalance(address);
|
||||
isCalling = false;
|
||||
}, interval);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [getBalance]);
|
||||
|
||||
useEffect(() => {
|
||||
if (authenticateOnMount) {
|
||||
authenticateUser();
|
||||
}
|
||||
if(userAccountInfo?.address && userAccountInfo?.publicKey){
|
||||
authenticateUser(userAccountInfo);
|
||||
}
|
||||
}, [authenticateOnMount, authenticateUser, userAccountInfo?.address, userAccountInfo?.publicKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (address && (balanceSetting?.onlyOnMount || (balanceSetting?.interval && !isNaN(balanceSetting?.interval)))) {
|
||||
getBalance(address);
|
||||
}
|
||||
if (address && balanceSetting?.interval !== undefined && !isNaN(balanceSetting.interval)) {
|
||||
balanceSetInterval(address, balanceSetting.interval);
|
||||
}
|
||||
}, [balanceSetting?.onlyOnMount, balanceSetting?.interval, address, getBalance, balanceSetInterval]);
|
||||
|
||||
const manualGetBalance = useCallback(async () : Promise<number | Error> => {
|
||||
if(!address) throw new Error('Not authenticated')
|
||||
const res = await getBalance(address)
|
||||
return res
|
||||
}, [address])
|
||||
|
||||
return useMemo(() => ({
|
||||
address,
|
||||
publicKey,
|
||||
name,
|
||||
avatarUrl,
|
||||
balance,
|
||||
isLoadingUser,
|
||||
isLoadingInitialBalance,
|
||||
errorMessageLoadingUser: errorLoadingUser,
|
||||
authenticateUser,
|
||||
getBalance: manualGetBalance,
|
||||
}), [
|
||||
address,
|
||||
publicKey,
|
||||
name,
|
||||
avatarUrl,
|
||||
balance,
|
||||
isLoadingUser,
|
||||
isLoadingInitialBalance,
|
||||
errorLoadingUser,
|
||||
authenticateUser,
|
||||
manualGetBalance,
|
||||
]);
|
||||
if (nameInfo?.owner !== currentAddress)
|
||||
throw new Error(`This account does not own the name ${name}`);
|
||||
setName(name);
|
||||
},
|
||||
[setName]
|
||||
);
|
||||
return useMemo(
|
||||
() => ({
|
||||
address,
|
||||
publicKey,
|
||||
name,
|
||||
avatarUrl,
|
||||
isLoadingUser,
|
||||
errorMessageLoadingUser: errorLoadingUser,
|
||||
authenticateUser,
|
||||
switchName,
|
||||
}),
|
||||
[
|
||||
address,
|
||||
publicKey,
|
||||
name,
|
||||
avatarUrl,
|
||||
isLoadingUser,
|
||||
errorLoadingUser,
|
||||
authenticateUser,
|
||||
switchName,
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
48
src/hooks/useBalance.tsx
Normal file
48
src/hooks/useBalance.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useAuthStore } from "../state/auth";
|
||||
|
||||
export const useQortBalance = () => {
|
||||
const address = useAuthStore((s) => s.address);
|
||||
const setBalance = useAuthStore((s) => s.setBalance);
|
||||
const isLoadingInitialBalance = useAuthStore(
|
||||
(s) => s.isLoadingInitialBalance
|
||||
);
|
||||
const setIsLoadingBalance = useAuthStore((s) => s.setIsLoadingBalance);
|
||||
|
||||
const qortBalance = useAuthStore((s) => s.balance);
|
||||
|
||||
const getBalance = useCallback(
|
||||
async (address: string): Promise<number> => {
|
||||
try {
|
||||
setIsLoadingBalance(true);
|
||||
const response = await qortalRequest({
|
||||
action: "GET_BALANCE",
|
||||
address,
|
||||
});
|
||||
const userBalance = Number(response) || 0;
|
||||
setBalance(userBalance);
|
||||
return userBalance;
|
||||
} catch (error) {
|
||||
setBalance(0);
|
||||
return 0;
|
||||
} finally {
|
||||
setIsLoadingBalance(false);
|
||||
}
|
||||
},
|
||||
[setBalance]
|
||||
);
|
||||
|
||||
const manualGetBalance = useCallback(async (): Promise<number | Error> => {
|
||||
if (!address) throw new Error("Not authenticated");
|
||||
const res = await getBalance(address);
|
||||
return res;
|
||||
}, [address]);
|
||||
return useMemo(
|
||||
() => ({
|
||||
value: qortBalance,
|
||||
getBalance: manualGetBalance,
|
||||
isLoading: isLoadingInitialBalance,
|
||||
}),
|
||||
[qortBalance, manualGetBalance, isLoadingInitialBalance]
|
||||
);
|
||||
};
|
||||
42
src/hooks/useBlockedNames.tsx
Normal file
42
src/hooks/useBlockedNames.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useListStore } from "../state/lists";
|
||||
import { useCacheStore } from "../state/cache";
|
||||
|
||||
export const useBlockedNames = () => {
|
||||
const filterOutItemsByNames = useListStore(state => state.filterOutItemsByNames)
|
||||
const filterSearchCacheItemsByNames = useCacheStore((s)=> s.filterSearchCacheItemsByNames)
|
||||
|
||||
|
||||
const addToBlockedList = useCallback(async (names: string[]) => {
|
||||
const response = await qortalRequest({
|
||||
action: "ADD_LIST_ITEMS",
|
||||
list_name: "blockedNames",
|
||||
items: names,
|
||||
});
|
||||
if (response === true) {
|
||||
filterOutItemsByNames(names)
|
||||
filterSearchCacheItemsByNames(names)
|
||||
return true;
|
||||
} else throw new Error("Unable to block names");
|
||||
}, []);
|
||||
|
||||
const removeFromBlockedList = useCallback(async (names: string[]) => {
|
||||
const response = await qortalRequest({
|
||||
action: "DELETE_LIST_ITEM",
|
||||
list_name: "blockedNames",
|
||||
items: names,
|
||||
});
|
||||
if (response === true) {
|
||||
return true;
|
||||
} else throw new Error("Unable to remove blocked names");
|
||||
}, []);
|
||||
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
removeFromBlockedList,
|
||||
addToBlockedList,
|
||||
}),
|
||||
[addToBlockedList, removeFromBlockedList]
|
||||
);
|
||||
};
|
||||
451
src/hooks/useGlobalPipPlayer.tsx
Normal file
451
src/hooks/useGlobalPipPlayer.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
// GlobalVideoPlayer.tsx
|
||||
import videojs from "video.js";
|
||||
import { useGlobalPlayerStore } from "../state/pip";
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { Box, IconButton } from "@mui/material";
|
||||
import { VideoContainer } from "../components/VideoPlayer/VideoPlayer-styles";
|
||||
import { Rnd } from "react-rnd";
|
||||
import { useProgressStore, useVideoStore } from "../state/video";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import PauseIcon from "@mui/icons-material/Pause";
|
||||
import OpenInFullIcon from "@mui/icons-material/OpenInFull";
|
||||
import { GlobalContext } from "../context/GlobalProvider";
|
||||
import { isTouchDevice } from "../components/VideoPlayer/VideoPlayer";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
export const GlobalPipPlayer = () => {
|
||||
const {
|
||||
videoSrc,
|
||||
reset,
|
||||
isPlaying,
|
||||
location,
|
||||
type,
|
||||
currentTime,
|
||||
mode,
|
||||
videoId,
|
||||
} = useGlobalPlayerStore();
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [hasStarted, setHasStarted] = useState(false);
|
||||
const playerRef = useRef<any>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const volume = useVideoStore(
|
||||
(state) => state.playbackSettings.volume
|
||||
);
|
||||
const context = useContext(GlobalContext);
|
||||
const navigate = useNavigate()
|
||||
const videoNode = useRef<HTMLVideoElement>(null);
|
||||
const hideTimeoutRef = useRef<number | null>(null);
|
||||
const { setProgress } = useProgressStore();
|
||||
|
||||
const updateProgress = useCallback(() => {
|
||||
const player = playerRef?.current;
|
||||
if (!player || typeof player?.currentTime !== "function") return;
|
||||
|
||||
const currentTime = player.currentTime();
|
||||
if (typeof currentTime === "number" && videoId && currentTime > 0.1) {
|
||||
setProgress(videoId, currentTime);
|
||||
}
|
||||
}, [videoId]);
|
||||
|
||||
const rndRef = useRef<any>(null);
|
||||
useEffect(() => {
|
||||
if (!playerRef.current && videoNode.current) {
|
||||
playerRef.current = videojs(videoNode.current, {
|
||||
autoplay: true,
|
||||
controls: false,
|
||||
responsive: true,
|
||||
fluid: true,
|
||||
});
|
||||
playerRef.current?.on("error", () => {
|
||||
// Optional: display user-friendly message
|
||||
});
|
||||
|
||||
// Resume playback if needed
|
||||
playerRef.current.on("ready", () => {
|
||||
if (videoSrc) {
|
||||
playerRef.current.src(videoSrc);
|
||||
playerRef.current.currentTime(currentTime);
|
||||
if (isPlaying) playerRef.current.play();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
// optional: don't destroy, just hide
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoSrc) {
|
||||
setHasStarted(false);
|
||||
}
|
||||
}, [videoSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
const player = playerRef.current;
|
||||
|
||||
if (!player) return;
|
||||
|
||||
if (!videoSrc && player.src) {
|
||||
// Only pause the player and unload the source without re-triggering playback
|
||||
player.pause();
|
||||
|
||||
|
||||
// player.src({ src: '', type: '' }); // ⬅️ this is the safe way to clear it
|
||||
|
||||
|
||||
setPlaying(false);
|
||||
setHasStarted(false);
|
||||
|
||||
// Optionally clear the poster and currentTime
|
||||
player.poster("");
|
||||
player.currentTime(0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoSrc) {
|
||||
// Set source and resume if needed
|
||||
player.src({ src: videoSrc, type: type });
|
||||
player.currentTime(currentTime);
|
||||
player.volume(volume)
|
||||
if (isPlaying) {
|
||||
const playPromise = player.play();
|
||||
|
||||
if (playPromise?.catch) {
|
||||
playPromise.catch((err: any) => {
|
||||
console.warn("Unable to autoplay:", err);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
player.pause();
|
||||
}
|
||||
}
|
||||
}, [videoSrc, type, isPlaying, currentTime, volume]);
|
||||
|
||||
// const onDragStart = () => {
|
||||
// timer = Date.now();
|
||||
// isDragging.current = true;
|
||||
// };
|
||||
|
||||
// const handleStopDrag = async () => {
|
||||
// const time = Date.now();
|
||||
// if (timer && time - timer < 300) {
|
||||
// isDragging.current = false;
|
||||
// } else {
|
||||
// isDragging.current = true;
|
||||
// }
|
||||
// };
|
||||
// const onDragStop = () => {
|
||||
// handleStopDrag();
|
||||
// };
|
||||
|
||||
// const checkIfDrag = useCallback(() => {
|
||||
// return isDragging.current;
|
||||
// }, []);
|
||||
const margin = 50;
|
||||
|
||||
const [height, setHeight] = useState(300);
|
||||
const [width, setWidth] = useState(400);
|
||||
const savedHeightRef = useRef<null | number>(null)
|
||||
const savedWidthRef = useRef<null | number>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoSrc) return;
|
||||
|
||||
const screenWidth = window.innerWidth;
|
||||
const screenHeight = window.innerHeight;
|
||||
const aspectRatio = 0.75; // 300 / 400 = 3:4
|
||||
|
||||
const maxWidthByScreen = screenWidth * 0.75;
|
||||
const maxWidthByHeight = (screenHeight * 0.3) / aspectRatio;
|
||||
|
||||
const maxWidth = savedWidthRef.current || Math.min(maxWidthByScreen, maxWidthByHeight);
|
||||
const maxHeight = savedHeightRef.current || maxWidth * aspectRatio;
|
||||
|
||||
setWidth(maxWidth);
|
||||
setHeight(maxHeight);
|
||||
|
||||
rndRef.current.updatePosition({
|
||||
x: screenWidth - maxWidth - margin,
|
||||
y: screenHeight - maxHeight - margin,
|
||||
width: maxWidth,
|
||||
height: maxHeight,
|
||||
});
|
||||
}, [videoSrc]);
|
||||
|
||||
const [showControls, setShowControls] = useState(false);
|
||||
|
||||
const handleMouseMove = () => {
|
||||
if (isTouchDevice) return;
|
||||
setShowControls(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (isTouchDevice) return;
|
||||
setShowControls(false);
|
||||
};
|
||||
const startPlay = useCallback(() => {
|
||||
try {
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
try {
|
||||
player.play();
|
||||
} catch (err) {
|
||||
console.warn("Play failed:", err);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("togglePlay", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopPlay = useCallback(() => {
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
try {
|
||||
player.pause();
|
||||
} catch (err) {
|
||||
console.warn("Play failed:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onPlayHandlerStart = useCallback(() => {
|
||||
setPlaying(true);
|
||||
setHasStarted(true);
|
||||
}, [setPlaying]);
|
||||
const onPlayHandlerStop = useCallback(() => {
|
||||
setPlaying(false);
|
||||
}, [setPlaying]);
|
||||
|
||||
const resetHideTimeout = () => {
|
||||
setShowControls(true);
|
||||
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
|
||||
hideTimeoutRef.current = setTimeout(() => {
|
||||
setShowControls(false);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!videoSrc || !container) return;
|
||||
|
||||
const handleInteraction = () => {
|
||||
resetHideTimeout();
|
||||
};
|
||||
|
||||
container.addEventListener("touchstart", handleInteraction, {
|
||||
passive: true,
|
||||
capture: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("touchstart", handleInteraction, {
|
||||
capture: true,
|
||||
});
|
||||
};
|
||||
}, [videoSrc]);
|
||||
|
||||
|
||||
return (
|
||||
<Rnd
|
||||
enableResizing={{
|
||||
top: false,
|
||||
right: false,
|
||||
bottom: false,
|
||||
left: false,
|
||||
topRight: true,
|
||||
bottomLeft: true,
|
||||
topLeft: true,
|
||||
bottomRight: true,
|
||||
}}
|
||||
ref={rndRef}
|
||||
// onDragStart={onDragStart}
|
||||
// onDragStop={onDragStop}
|
||||
style={{
|
||||
display: hasStarted ? "block" : "none",
|
||||
position: "fixed",
|
||||
zIndex: 999999999,
|
||||
|
||||
cursor: "default",
|
||||
}}
|
||||
size={{ width, height }}
|
||||
onResize={(e, direction, ref, delta, position) => {
|
||||
setWidth(ref.offsetWidth);
|
||||
setHeight(ref.offsetHeight);
|
||||
savedHeightRef.current = ref.offsetHeight
|
||||
savedWidthRef.current = ref.offsetWidth
|
||||
}}
|
||||
// default={{
|
||||
// x: 500,
|
||||
// y: 500,
|
||||
// width: 350,
|
||||
// height: "auto",
|
||||
// }}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
onDrag={() => {}}
|
||||
>
|
||||
{/* <div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
// width: '100px',
|
||||
// height: '100px',
|
||||
zIndex: 9999,
|
||||
display: videoSrc ? 'block' : 'none'
|
||||
}}
|
||||
> */}
|
||||
<Box
|
||||
ref={containerRef}
|
||||
sx={{
|
||||
height,
|
||||
pointerEvents: "auto",
|
||||
width,
|
||||
position: "relative",
|
||||
background: "black",
|
||||
overflow: "hidden",
|
||||
borderRadius: "10px",
|
||||
}}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* {backgroundColor: showControls ? 'rgba(0,0,0,.5)' : 'unset'} */}
|
||||
{showControls && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1,
|
||||
opacity: showControls ? 1 : 0,
|
||||
pointerEvents: showControls ? "auto" : "none",
|
||||
transition: "opacity 0.5s ease-in-out",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
background: "rgba(0,0,0,.5)",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1,
|
||||
opacity: showControls ? 1 : 0,
|
||||
pointerEvents: showControls ? "auto" : "none",
|
||||
transition: "opacity 0.5s ease-in-out",
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 5,
|
||||
opacity: 1,
|
||||
right: 5,
|
||||
zIndex: 2,
|
||||
background: 'rgba(0,0,0,0.3)',
|
||||
borderRadius: '50%',
|
||||
padding: '5px'
|
||||
}}
|
||||
onClick={reset}
|
||||
onTouchStart={reset}
|
||||
>
|
||||
<CloseIcon sx={{
|
||||
color: 'white',
|
||||
}} />
|
||||
</IconButton>
|
||||
{location && (
|
||||
<IconButton
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 5,
|
||||
left: 5,
|
||||
zIndex: 2,
|
||||
opacity: 1,
|
||||
background: 'rgba(0,0,0,0.3)',
|
||||
borderRadius: '50%',
|
||||
padding: '5px'
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (navigate) {
|
||||
navigate(location);
|
||||
}
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation()
|
||||
if (navigate) {
|
||||
navigate(location);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<OpenInFullIcon sx={{
|
||||
color: 'white',
|
||||
}} />
|
||||
</IconButton>
|
||||
)}
|
||||
{playing && (
|
||||
<IconButton
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
opacity: 1,
|
||||
zIndex: 2,
|
||||
background: 'rgba(0,0,0,0.3)',
|
||||
borderRadius: '50%',
|
||||
padding: '5px'
|
||||
}}
|
||||
onClick={stopPlay}
|
||||
onTouchStart={stopPlay}
|
||||
>
|
||||
<PauseIcon sx={{
|
||||
color: 'white',
|
||||
}} />
|
||||
</IconButton>
|
||||
)}
|
||||
{!playing && (
|
||||
<IconButton
|
||||
sx={{
|
||||
position: "absolute",
|
||||
opacity: 1,
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
zIndex: 2,
|
||||
background: 'rgba(0,0,0,0.3)',
|
||||
borderRadius: '50%',
|
||||
padding: '5px'
|
||||
}}
|
||||
onClick={startPlay}
|
||||
onTouchStart={startPlay}
|
||||
>
|
||||
<PlayArrowIcon sx={{
|
||||
color: 'white',
|
||||
}} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
<Box />
|
||||
</Box>
|
||||
)}
|
||||
<VideoContainer>
|
||||
<video
|
||||
onPlay={onPlayHandlerStart}
|
||||
onPause={onPlayHandlerStop}
|
||||
onTimeUpdate={updateProgress}
|
||||
ref={videoNode}
|
||||
className="video-js"
|
||||
style={{}}
|
||||
/>
|
||||
</VideoContainer>
|
||||
</Box>
|
||||
{/* </div> */}
|
||||
</Rnd>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import {
|
||||
buildIdentifier,
|
||||
buildLooseIdentifier,
|
||||
buildLooseSearchPrefix,
|
||||
buildSearchPrefix,
|
||||
EnumCollisionStrength,
|
||||
hashWord,
|
||||
@@ -60,6 +62,8 @@ export const useIdentifiers = (publicSalt: string, appName: string) => {
|
||||
createSingleIdentifier,
|
||||
hashQortalName,
|
||||
hashString,
|
||||
buildLooseSearchPrefix,
|
||||
buildLooseIdentifier
|
||||
}),
|
||||
[
|
||||
buildIdentifierFunc,
|
||||
|
||||
44
src/hooks/useIframe.tsx
Normal file
44
src/hooks/useIframe.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useEffect } from "react";
|
||||
import { supportedLanguages } from "../i18n/i18n";
|
||||
import i18n from '../i18n/i18n'
|
||||
type Language = "de" | "en" | "es" | "fr" | "it" | "ja" | "ru" | "zh";
|
||||
type Theme = "dark" | "light";
|
||||
|
||||
interface CustomWindow extends Window {
|
||||
_qdnTheme: Theme;
|
||||
_qdnLang: Language;
|
||||
}
|
||||
const customWindow = window as unknown as CustomWindow;
|
||||
|
||||
|
||||
export const useIframe = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const languageDefault = customWindow?._qdnLang;
|
||||
|
||||
if (supportedLanguages?.includes(languageDefault)) {
|
||||
i18n.changeLanguage(languageDefault);
|
||||
}
|
||||
|
||||
function handleNavigation(event: {
|
||||
data: {
|
||||
action: string;
|
||||
language: Language;
|
||||
};
|
||||
}) {
|
||||
|
||||
if (event.data?.action === "LANGUAGE_CHANGED" && event.data.language) {
|
||||
if (!supportedLanguages?.includes(event.data.language)) return;
|
||||
|
||||
i18n.changeLanguage(event.data.language);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleNavigation);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("message", handleNavigation);
|
||||
};
|
||||
}, []);
|
||||
return;
|
||||
};
|
||||
185
src/hooks/useInitializeAuth.tsx
Normal file
185
src/hooks/useInitializeAuth.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useAuthStore } from "../state/auth";
|
||||
|
||||
// ✅ Define Types
|
||||
/**
|
||||
* Configuration for balance retrieval behavior.
|
||||
*/
|
||||
export type BalanceSetting =
|
||||
| {
|
||||
/** If `true`, the balance will be fetched only once when the app loads. */
|
||||
onlyOnMount: true;
|
||||
/** `interval` cannot be set when `onlyOnMount` is `true`. */
|
||||
interval?: never;
|
||||
}
|
||||
| {
|
||||
/** If `false` or omitted, balance will be updated periodically. */
|
||||
onlyOnMount?: false;
|
||||
/** The time interval (in milliseconds) for balance updates. */
|
||||
interval?: number;
|
||||
};
|
||||
|
||||
export interface userAccountInfo {
|
||||
address: string;
|
||||
publicKey: string;
|
||||
}
|
||||
export interface UseAuthProps {
|
||||
balanceSetting?: BalanceSetting;
|
||||
/** User will be prompted for authentication on start-up */
|
||||
authenticateOnMount?: boolean;
|
||||
userAccountInfo?: userAccountInfo | null;
|
||||
}
|
||||
|
||||
export const useAuth = ({
|
||||
balanceSetting,
|
||||
authenticateOnMount = true,
|
||||
userAccountInfo = null,
|
||||
}: UseAuthProps) => {
|
||||
const address = useAuthStore((s) => s.address);
|
||||
const publicKey = useAuthStore((s) => s.publicKey);
|
||||
const name = useAuthStore((s) => s.name);
|
||||
const avatarUrl = useAuthStore((s) => s.avatarUrl);
|
||||
|
||||
const isLoadingUser = useAuthStore((s) => s.isLoadingUser);
|
||||
const errorLoadingUser = useAuthStore((s) => s.errorLoadingUser);
|
||||
const setIsLoadingBalance = useAuthStore((s) => s.setIsLoadingBalance);
|
||||
|
||||
const setErrorLoadingUser = useAuthStore((s) => s.setErrorLoadingUser);
|
||||
const setIsLoadingUser = useAuthStore((s) => s.setIsLoadingUser);
|
||||
const setUser = useAuthStore((s) => s.setUser);
|
||||
const setBalance = useAuthStore((s) => s.setBalance);
|
||||
|
||||
const balanceSetIntervalRef = useRef<null | ReturnType<typeof setInterval>>(
|
||||
null
|
||||
);
|
||||
|
||||
const authenticateUser = useCallback(
|
||||
async (userAccountInfo?: userAccountInfo) => {
|
||||
try {
|
||||
setErrorLoadingUser(null);
|
||||
setIsLoadingUser(true);
|
||||
|
||||
const account =
|
||||
userAccountInfo ||
|
||||
(await qortalRequest({
|
||||
action: "GET_USER_ACCOUNT",
|
||||
}));
|
||||
|
||||
if (account?.address) {
|
||||
const nameData = await qortalRequest({
|
||||
action: "GET_PRIMARY_NAME",
|
||||
address: account.address,
|
||||
});
|
||||
setUser({ ...account, name: nameData || "" });
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorLoadingUser(
|
||||
error instanceof Error ? error.message : "Unable to authenticate"
|
||||
);
|
||||
} finally {
|
||||
setIsLoadingUser(false);
|
||||
}
|
||||
},
|
||||
[setErrorLoadingUser, setIsLoadingUser, setUser]
|
||||
);
|
||||
|
||||
const getBalance = useCallback(
|
||||
async (address: string): Promise<number> => {
|
||||
try {
|
||||
setIsLoadingBalance(true);
|
||||
const response = await qortalRequest({
|
||||
action: "GET_BALANCE",
|
||||
address,
|
||||
});
|
||||
const userBalance = Number(response) || 0;
|
||||
setBalance(userBalance);
|
||||
return userBalance;
|
||||
} catch (error) {
|
||||
setBalance(0);
|
||||
return 0;
|
||||
} finally {
|
||||
setIsLoadingBalance(false);
|
||||
}
|
||||
},
|
||||
[setBalance]
|
||||
);
|
||||
|
||||
const balanceSetInterval = useCallback(
|
||||
(address: string, interval: number) => {
|
||||
try {
|
||||
if (balanceSetIntervalRef.current) {
|
||||
clearInterval(balanceSetIntervalRef.current);
|
||||
}
|
||||
|
||||
let isCalling = false;
|
||||
balanceSetIntervalRef.current = setInterval(async () => {
|
||||
if (isCalling) return;
|
||||
isCalling = true;
|
||||
await getBalance(address);
|
||||
isCalling = false;
|
||||
}, interval);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
[getBalance]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (authenticateOnMount) {
|
||||
authenticateUser();
|
||||
}
|
||||
if (userAccountInfo?.address && userAccountInfo?.publicKey) {
|
||||
authenticateUser(userAccountInfo);
|
||||
}
|
||||
}, [
|
||||
authenticateOnMount,
|
||||
authenticateUser,
|
||||
userAccountInfo?.address,
|
||||
userAccountInfo?.publicKey,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
address &&
|
||||
(balanceSetting?.onlyOnMount ||
|
||||
(balanceSetting?.interval && !isNaN(balanceSetting?.interval)))
|
||||
) {
|
||||
getBalance(address);
|
||||
}
|
||||
if (
|
||||
address &&
|
||||
balanceSetting?.interval !== undefined &&
|
||||
!isNaN(balanceSetting.interval)
|
||||
) {
|
||||
balanceSetInterval(address, balanceSetting.interval);
|
||||
}
|
||||
}, [
|
||||
balanceSetting?.onlyOnMount,
|
||||
balanceSetting?.interval,
|
||||
address,
|
||||
getBalance,
|
||||
balanceSetInterval,
|
||||
]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
address,
|
||||
publicKey,
|
||||
name,
|
||||
avatarUrl,
|
||||
isLoadingUser,
|
||||
errorMessageLoadingUser: errorLoadingUser,
|
||||
authenticateUser,
|
||||
}),
|
||||
[
|
||||
address,
|
||||
publicKey,
|
||||
name,
|
||||
avatarUrl,
|
||||
isLoadingUser,
|
||||
errorLoadingUser,
|
||||
authenticateUser,
|
||||
]
|
||||
);
|
||||
};
|
||||
7
src/hooks/useLibTranslation.tsx
Normal file
7
src/hooks/useLibTranslation.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
// src/hooks/useLibTranslation.ts
|
||||
import { useTranslation as useTranslationOriginal } from 'react-i18next';
|
||||
import libI18n from '../i18n/i18n'
|
||||
|
||||
export function useLibTranslation(ns?: string | string[]) {
|
||||
return useTranslationOriginal(ns || 'lib-core', { i18n: libI18n });
|
||||
}
|
||||
16
src/hooks/useListData.tsx
Normal file
16
src/hooks/useListData.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { useListStore } from "../state/lists";
|
||||
import { useCacheStore } from "../state/cache"; // Assuming you export getResourceCache
|
||||
import { QortalMetadata } from "../types/interfaces/resources";
|
||||
|
||||
export function useListReturn(listName: string): QortalMetadata[] {
|
||||
const list = useListStore((state) => state.lists[listName]?.items) || [];
|
||||
const filterOutDeletedResources = useCacheStore((s) => s.filterOutDeletedResources);
|
||||
const deletedResources = useCacheStore((s) => s.deletedResources);
|
||||
const temporaryResources = useCacheStore().getTemporaryResources(listName)
|
||||
|
||||
const listToDisplay = useMemo(()=> {
|
||||
return filterOutDeletedResources([...temporaryResources, ...(list || [])])
|
||||
}, [list, listName, deletedResources, temporaryResources])
|
||||
return listToDisplay
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import { QortalGetMetadata, QortalMetadata } from "../types/interfaces/resources
|
||||
import { base64ToObject, retryTransaction } from "../utils/publish";
|
||||
import { useGlobal } from "../context/GlobalProvider";
|
||||
import { ReturnType } from "../components/ResourceList/ResourceListDisplay";
|
||||
import { useCacheStore } from "../state/cache";
|
||||
import { useMultiplePublishStore, usePublishStatusStore } from "../state/multiplePublish";
|
||||
import { ResourceToPublish } from "../types/qortalRequests/types";
|
||||
|
||||
const STORAGE_EXPIRY_DURATION = 5 * 60 * 1000;
|
||||
interface StoredPublish {
|
||||
qortalMetadata: QortalMetadata;
|
||||
data: any;
|
||||
@@ -22,13 +24,14 @@ interface StoredPublish {
|
||||
resource: { qortalMetadata: QortalMetadata; data: any } | null;
|
||||
error: string | null;
|
||||
}>
|
||||
fetchPublish: (metadataProp: QortalGetMetadata) => Promise<{
|
||||
fetchPublish: (metadataProp: QortalGetMetadata, disableFetch?: boolean) => Promise<{
|
||||
hasResource: boolean | null;
|
||||
resource: { qortalMetadata: QortalMetadata; data: any } | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
updatePublish: (publish: QortalGetMetadata, data: any) => Promise<void>;
|
||||
deletePublish: (publish: QortalGetMetadata) => Promise<boolean | undefined>;
|
||||
publishMultipleResources: (resources: ResourceToPublish[])=> Promise<Error | QortalGetMetadata[]>
|
||||
};
|
||||
|
||||
type UsePublishWithoutMetadata = {
|
||||
@@ -39,12 +42,15 @@ interface StoredPublish {
|
||||
}>;
|
||||
updatePublish: (publish: QortalGetMetadata, data: any) => Promise<void>;
|
||||
deletePublish: (publish: QortalGetMetadata) => Promise<boolean | undefined>;
|
||||
publishMultipleResources: (resources: ResourceToPublish[])=> Promise<Error | QortalGetMetadata[]>
|
||||
|
||||
};
|
||||
|
||||
export function usePublish(
|
||||
maxFetchTries: number,
|
||||
returnType: ReturnType,
|
||||
metadata: QortalGetMetadata
|
||||
metadata: QortalGetMetadata,
|
||||
disableFetch?: boolean
|
||||
): UsePublishWithMetadata;
|
||||
|
||||
export function usePublish(
|
||||
@@ -57,7 +63,8 @@ interface StoredPublish {
|
||||
export function usePublish(
|
||||
maxFetchTries: number = 3,
|
||||
returnType: ReturnType = "JSON",
|
||||
metadata?: QortalGetMetadata | null
|
||||
metadata?: QortalGetMetadata | null,
|
||||
disableFetch?: boolean
|
||||
): UsePublishWithMetadata | UsePublishWithoutMetadata {
|
||||
const {auth, appInfo} = useGlobal()
|
||||
const username = auth?.name
|
||||
@@ -68,7 +75,11 @@ interface StoredPublish {
|
||||
const publish = usePublishStore().getPublish(metadata || null, true);
|
||||
const setPublish = usePublishStore((state)=> state.setPublish)
|
||||
const getPublish = usePublishStore(state=> state.getPublish)
|
||||
|
||||
const setResourceCache = useCacheStore((s) => s.setResourceCache);
|
||||
const markResourceAsDeleted = useCacheStore((s) => s.markResourceAsDeleted);
|
||||
const setPublishStatusByKey = usePublishStatusStore((s)=> s.setPublishStatusByKey)
|
||||
const setPublishResources = useMultiplePublishStore((state) => state.setPublishResources);
|
||||
const resetPublishResources = useMultiplePublishStore((state) => state.reset);
|
||||
const [hasResource, setHasResource] = useState<boolean | null>(null);
|
||||
const fetchRawData = useCallback(async (item: QortalGetMetadata) => {
|
||||
const url = `/arbitrary/${item?.service}/${encodeURIComponent(item?.name)}/${encodeURIComponent(item?.identifier)}?encoding=base64`;
|
||||
@@ -85,34 +96,12 @@ interface StoredPublish {
|
||||
return `qortal_publish_${username}_${appNameHashed}`;
|
||||
}, [username, appNameHashed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!username || !appNameHashed) return;
|
||||
|
||||
const storageKey = getStorageKey();
|
||||
if (!storageKey) return;
|
||||
|
||||
const storedData: StoredPublish[] = JSON.parse(localStorage.getItem(storageKey) || "[]");
|
||||
|
||||
if (Array.isArray(storedData) && storedData.length > 0) {
|
||||
const now = Date.now();
|
||||
const validPublishes = storedData.filter((item) => now - item.timestamp < STORAGE_EXPIRY_DURATION);
|
||||
|
||||
// ✅ Re-populate the Zustand store only with recent publishes
|
||||
validPublishes.forEach((publishData) => {
|
||||
setPublish(publishData.qortalMetadata, {
|
||||
qortalMetadata: publishData.qortalMetadata,
|
||||
data: publishData.data
|
||||
}, Date.now() - publishData.timestamp);
|
||||
});
|
||||
|
||||
// ✅ Re-store only valid (non-expired) publishes
|
||||
localStorage.setItem(storageKey, JSON.stringify(validPublishes));
|
||||
}
|
||||
}, [username, appNameHashed, getStorageKey, setPublish]);
|
||||
|
||||
const fetchPublish = useCallback(
|
||||
async (
|
||||
metadataProp: QortalGetMetadata,
|
||||
disableFetch?: boolean
|
||||
) => {
|
||||
let hasResource = null;
|
||||
let resource = null;
|
||||
@@ -121,8 +110,9 @@ interface StoredPublish {
|
||||
if (metadata) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
const hasCache = getPublish(metadataProp)
|
||||
|
||||
|
||||
const hasCache = getPublish(metadataProp)
|
||||
|
||||
if(hasCache){
|
||||
if(hasCache?.qortalMetadata.size === 32){
|
||||
if(metadata){
|
||||
@@ -139,6 +129,7 @@ interface StoredPublish {
|
||||
if(metadata){
|
||||
setHasResource(true)
|
||||
setError(null)
|
||||
setPublish(metadataProp, hasCache);
|
||||
}
|
||||
return {
|
||||
resource: hasCache,
|
||||
@@ -146,6 +137,13 @@ interface StoredPublish {
|
||||
hasResource: true
|
||||
}
|
||||
}
|
||||
if(metadata && disableFetch){
|
||||
return {
|
||||
resource: null,
|
||||
error: null,
|
||||
hasResource: null
|
||||
}
|
||||
}
|
||||
const url = `/arbitrary/resources/search?mode=ALL&service=${metadataProp?.service}&limit=1&includemetadata=true&reverse=true&excludeblocked=true&name=${encodeURIComponent(metadataProp?.name)}&exactmatchnames=true&offset=0&identifier=${encodeURIComponent(metadataProp?.identifier)}`;
|
||||
const responseMetadata = await fetch(url, {
|
||||
method: "GET",
|
||||
@@ -221,9 +219,9 @@ interface StoredPublish {
|
||||
useEffect(() => {
|
||||
|
||||
if (metadata?.identifier && metadata?.name && metadata?.service) {
|
||||
fetchPublish(metadata);
|
||||
fetchPublish(metadata, disableFetch);
|
||||
}
|
||||
}, [metadata?.identifier, metadata?.service, metadata?.identifier, returnType]);
|
||||
}, [metadata?.identifier, metadata?.service, metadata?.identifier, returnType, disableFetch]);
|
||||
|
||||
const refetchData = useCallback(async ()=> {
|
||||
if(!metadata) throw new Error('usePublish is missing metadata')
|
||||
@@ -240,30 +238,11 @@ interface StoredPublish {
|
||||
});
|
||||
|
||||
if (res?.signature) {
|
||||
const storageKey = getStorageKey();
|
||||
if (storageKey) {
|
||||
const existingPublishes = JSON.parse(localStorage.getItem(storageKey) || "[]");
|
||||
|
||||
// Remove any previous entries for the same identifier
|
||||
const updatedPublishes = existingPublishes.filter(
|
||||
(item: StoredPublish) => item.qortalMetadata.identifier !== publish.identifier && item.qortalMetadata.service !== publish.service && item.qortalMetadata.name !== publish.name
|
||||
);
|
||||
|
||||
// Add the new one with timestamp
|
||||
updatedPublishes.push({ qortalMetadata: {
|
||||
...publish,
|
||||
created: Date.now(),
|
||||
updated: Date.now(),
|
||||
size: 32
|
||||
}, data: "RA==", timestamp: Date.now() });
|
||||
|
||||
// Save back to storage
|
||||
localStorage.setItem(storageKey, JSON.stringify(updatedPublishes));
|
||||
}
|
||||
setPublish(publish, null);
|
||||
setError(null)
|
||||
setIsLoading(false)
|
||||
setHasResource(false)
|
||||
markResourceAsDeleted(publish)
|
||||
return true;
|
||||
}
|
||||
}, [getStorageKey]);
|
||||
@@ -279,35 +258,79 @@ interface StoredPublish {
|
||||
updated: Date.now(),
|
||||
size: 100
|
||||
}, data});
|
||||
|
||||
const storageKey = getStorageKey();
|
||||
if (storageKey) {
|
||||
const existingPublishes = JSON.parse(localStorage.getItem(storageKey) || "[]");
|
||||
|
||||
// Remove any previous entries for the same identifier
|
||||
const updatedPublishes = existingPublishes.filter(
|
||||
(item: StoredPublish) => item.qortalMetadata.identifier !== publish.identifier && item.qortalMetadata.service !== publish.service && item.qortalMetadata.name !== publish.name
|
||||
);
|
||||
|
||||
// Add the new one with timestamp
|
||||
updatedPublishes.push({ qortalMetadata: {
|
||||
...publish,
|
||||
setResourceCache(
|
||||
`${publish?.service}-${publish?.name}-${publish?.identifier}`,
|
||||
{qortalMetadata: {
|
||||
...publish,
|
||||
created: Date.now(),
|
||||
updated: Date.now(),
|
||||
size: 100
|
||||
}, data, timestamp: Date.now() });
|
||||
}, data}
|
||||
);
|
||||
|
||||
// Save back to storage
|
||||
localStorage.setItem(storageKey, JSON.stringify(updatedPublishes));
|
||||
}
|
||||
|
||||
}, [getStorageKey, setPublish]);
|
||||
|
||||
const publishMultipleResources = useCallback(async (resources: ResourceToPublish[]): Promise<Error | QortalGetMetadata[]> => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const store = useMultiplePublishStore.getState();
|
||||
const storeStatus = usePublishStatusStore.getState();
|
||||
|
||||
store.setPublishResources(resources);
|
||||
store.setIsPublishing(true);
|
||||
store.setCompletionResolver(resolve);
|
||||
store.setRejectionResolver(reject);
|
||||
try {
|
||||
store.setIsLoading(true);
|
||||
setPublishResources(resources);
|
||||
store.setError(null)
|
||||
store.setFailedPublishResources([])
|
||||
const lengthOfResources = resources?.length;
|
||||
const lengthOfTimeout = lengthOfResources * 1200000; // 20 minutes per resource
|
||||
const result = await qortalRequestWithTimeout({
|
||||
action: "PUBLISH_MULTIPLE_QDN_RESOURCES",
|
||||
resources
|
||||
}, lengthOfTimeout);
|
||||
|
||||
store.complete(result);
|
||||
store.reset()
|
||||
storeStatus.reset()
|
||||
} catch (error: any) {
|
||||
const unPublished = error?.error?.unsuccessfulPublishes;
|
||||
const failedPublishes: QortalGetMetadata[] = []
|
||||
if (unPublished && Array.isArray(unPublished)) {
|
||||
unPublished.forEach((item) => {
|
||||
const key = `${item?.service}-${item?.name}-${item?.identifier}`;
|
||||
|
||||
setPublishStatusByKey(key, {
|
||||
error: {
|
||||
reason: item.reason
|
||||
}
|
||||
});
|
||||
failedPublishes.push({
|
||||
name: item?.name,
|
||||
service: item?.service,
|
||||
identifier: item?.identifier
|
||||
})
|
||||
});
|
||||
store.setFailedPublishResources(failedPublishes)
|
||||
} else {
|
||||
store.setError(error?.message || 'Error during publish')
|
||||
}
|
||||
|
||||
} finally {
|
||||
store.setIsLoading(false);
|
||||
}
|
||||
})
|
||||
}, [setPublishResources]);
|
||||
|
||||
|
||||
if (!metadata)
|
||||
return {
|
||||
fetchPublish,
|
||||
updatePublish,
|
||||
deletePublish: deleteResource,
|
||||
publishMultipleResources
|
||||
};
|
||||
|
||||
return useMemo(() => ({
|
||||
@@ -319,6 +342,7 @@ interface StoredPublish {
|
||||
fetchPublish,
|
||||
updatePublish,
|
||||
deletePublish: deleteResource,
|
||||
publishMultipleResources
|
||||
}), [
|
||||
isLoading,
|
||||
error,
|
||||
@@ -328,6 +352,7 @@ interface StoredPublish {
|
||||
fetchPublish,
|
||||
updatePublish,
|
||||
deleteResource,
|
||||
publishMultipleResources
|
||||
]);
|
||||
|
||||
};
|
||||
|
||||
@@ -5,45 +5,78 @@ import { QortalGetMetadata } from "../types/interfaces/resources";
|
||||
interface PropsUseResourceStatus {
|
||||
resource: QortalGetMetadata | null;
|
||||
retryAttempts?: number;
|
||||
path?: string;
|
||||
filename?: string;
|
||||
isGlobal?: boolean;
|
||||
disableAutoFetch?: boolean;
|
||||
}
|
||||
export const useResourceStatus = ({
|
||||
resource,
|
||||
retryAttempts = 50,
|
||||
retryAttempts = 40,
|
||||
path,
|
||||
filename,
|
||||
isGlobal,
|
||||
disableAutoFetch,
|
||||
}: PropsUseResourceStatus) => {
|
||||
const resourceId = !resource ? null : `${resource.service}-${resource.name}-${resource.identifier}`;
|
||||
const status = usePublishStore((state)=> state.getResourceStatus(resourceId)) || null
|
||||
const intervalRef = useRef<null | number>(null)
|
||||
const timeoutRef = useRef<null | number>(null)
|
||||
const resourceId = !resource
|
||||
? null
|
||||
: `${resource.service}-${resource.name}-${resource.identifier}`;
|
||||
const status =
|
||||
usePublishStore((state) => state.getResourceStatus(resourceId)) || null;
|
||||
const intervalRef = useRef<any>(null);
|
||||
const timeoutRef = useRef<any>(null);
|
||||
const setResourceStatus = usePublishStore((state) => state.setResourceStatus);
|
||||
const statusRef = useRef<ResourceStatus | null>(null)
|
||||
const getResourceStatus = usePublishStore((state) => state.getResourceStatus);
|
||||
|
||||
useEffect(()=> {
|
||||
statusRef.current = status
|
||||
}, [status])
|
||||
const statusRef = useRef<ResourceStatus | null>(null);
|
||||
const startGlobalDownload = usePublishStore(
|
||||
(state) => state.startGlobalDownload
|
||||
);
|
||||
const stopGlobalDownload = usePublishStore(
|
||||
(state) => state.stopGlobalDownload
|
||||
);
|
||||
useEffect(() => {
|
||||
statusRef.current = status;
|
||||
}, [status]);
|
||||
const downloadResource = useCallback(
|
||||
({ service, name, identifier }: QortalGetMetadata, build?: boolean) => {
|
||||
(
|
||||
{ service, name, identifier }: QortalGetMetadata,
|
||||
build?: boolean,
|
||||
isRecalling?: boolean
|
||||
) => {
|
||||
try {
|
||||
if(statusRef.current && statusRef.current?.status === 'READY'){
|
||||
if (statusRef.current && statusRef.current?.status === "READY") {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
return
|
||||
intervalRef.current = null;
|
||||
timeoutRef.current = null;
|
||||
return;
|
||||
}
|
||||
setResourceStatus(
|
||||
if (!isRecalling) {
|
||||
const id = `${service}-${name}-${identifier}`
|
||||
const resourceStatus = getResourceStatus(id)
|
||||
if(!resourceStatus){
|
||||
setResourceStatus(
|
||||
{ service, name, identifier },
|
||||
{
|
||||
"status": "SEARCHING",
|
||||
"localChunkCount": 0,
|
||||
"totalChunkCount": 0,
|
||||
"percentLoaded": 0
|
||||
status: "SEARCHING",
|
||||
localChunkCount: 0,
|
||||
totalChunkCount: 0,
|
||||
percentLoaded: 0,
|
||||
path: path || "",
|
||||
filename: filename || "",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
let isCalling = false;
|
||||
let percentLoaded = 0;
|
||||
let timer = 24;
|
||||
let timer = 29;
|
||||
let tries = 0;
|
||||
let calledFirstTime = false;
|
||||
const callFunction = async () => {
|
||||
@@ -52,20 +85,19 @@ export const useResourceStatus = ({
|
||||
|
||||
let res;
|
||||
if (!build) {
|
||||
const urlFirstTime = `/arbitrary/resource/status/${service}/${name}/${identifier}`;
|
||||
const resCall = await fetch(urlFirstTime, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
res = await qortalRequest({
|
||||
action: "GET_QDN_RESOURCE_STATUS",
|
||||
name: name,
|
||||
service: service,
|
||||
identifier: identifier,
|
||||
});
|
||||
res = await resCall.json();
|
||||
|
||||
setResourceStatus(
|
||||
{ service, name, identifier },
|
||||
{
|
||||
...res
|
||||
}
|
||||
);
|
||||
{ service, name, identifier },
|
||||
{
|
||||
...res,
|
||||
}
|
||||
);
|
||||
if (tries > retryAttempts) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
@@ -73,6 +105,8 @@ export const useResourceStatus = ({
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
intervalRef.current = null;
|
||||
timeoutRef.current = null;
|
||||
setResourceStatus(
|
||||
{ service, name, identifier },
|
||||
{
|
||||
@@ -107,11 +141,11 @@ export const useResourceStatus = ({
|
||||
) {
|
||||
timer = timer - 5;
|
||||
} else {
|
||||
timer = 24;
|
||||
timer = 29;
|
||||
}
|
||||
|
||||
if (timer < 0) {
|
||||
timer = 24;
|
||||
timer = 29;
|
||||
isCalling = true;
|
||||
|
||||
setResourceStatus(
|
||||
@@ -124,8 +158,8 @@ export const useResourceStatus = ({
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
isCalling = false;
|
||||
downloadResource({ name, service, identifier }, true);
|
||||
}, 25000);
|
||||
downloadResource({ name, service, identifier }, true, true);
|
||||
}, 10000);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -140,7 +174,6 @@ export const useResourceStatus = ({
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Check if progress is 100% and clear interval if true
|
||||
if (res?.status === "READY") {
|
||||
if (intervalRef.current) {
|
||||
@@ -149,63 +182,158 @@ export const useResourceStatus = ({
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
setResourceStatus({service, name, identifier}, {
|
||||
intervalRef.current = null;
|
||||
timeoutRef.current = null;
|
||||
setResourceStatus(
|
||||
{ service, name, identifier },
|
||||
{
|
||||
...res,
|
||||
})
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (res?.status === "DOWNLOADED") {
|
||||
const url = `/arbitrary/resource/status/${service}/${name}/${identifier}?build=true`;
|
||||
const resCall = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
res = await qortalRequest({
|
||||
action: "GET_QDN_RESOURCE_STATUS",
|
||||
name: name,
|
||||
service: service,
|
||||
identifier: identifier,
|
||||
build: true,
|
||||
});
|
||||
res = await resCall.json();
|
||||
}
|
||||
};
|
||||
callFunction();
|
||||
intervalRef.current = setInterval(async () => {
|
||||
callFunction();
|
||||
}, 5000);
|
||||
|
||||
if (!intervalRef.current) {
|
||||
intervalRef.current = setInterval(callFunction, 5000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during resource fetch:", error);
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
},
|
||||
[retryAttempts]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (disableAutoFetch) return;
|
||||
if (resource?.identifier && resource?.name && resource?.service) {
|
||||
downloadResource({
|
||||
service: resource?.service,
|
||||
name: resource?.name,
|
||||
identifier: resource?.identifier,
|
||||
});
|
||||
const id = `${resource.service}-${resource.name}-${resource.identifier}`;
|
||||
|
||||
if (isGlobal) {
|
||||
startGlobalDownload(id, resource, retryAttempts, path, filename);
|
||||
} else {
|
||||
statusRef.current = null;
|
||||
downloadResource({
|
||||
service: resource?.service,
|
||||
name: resource?.name,
|
||||
identifier: resource?.identifier,
|
||||
});
|
||||
}
|
||||
}
|
||||
return ()=> {
|
||||
if(intervalRef.current){
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
if(timeoutRef.current){
|
||||
clearTimeout(timeoutRef.current)
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
resource?.identifier,
|
||||
resource?.name,
|
||||
resource?.service,
|
||||
downloadResource,
|
||||
isGlobal,
|
||||
retryAttempts,
|
||||
path,
|
||||
filename,
|
||||
disableAutoFetch,
|
||||
]);
|
||||
|
||||
const handledownloadResource = useCallback(() => {
|
||||
if (resource?.identifier && resource?.name && resource?.service) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
const id = `${resource?.service}-${resource?.name}-${resource?.identifier}`
|
||||
const resourceStatus = getResourceStatus(id)
|
||||
if(!resourceStatus){
|
||||
setResourceStatus(
|
||||
{
|
||||
service: resource.service,
|
||||
name: resource.name,
|
||||
identifier: resource.identifier,
|
||||
},
|
||||
{
|
||||
status: "SEARCHING",
|
||||
localChunkCount: 0,
|
||||
totalChunkCount: 0,
|
||||
percentLoaded: 0,
|
||||
path: path || "",
|
||||
filename: filename || "",
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isGlobal) {
|
||||
const id = `${resource.service}-${resource.name}-${resource.identifier}`;
|
||||
stopGlobalDownload(id);
|
||||
startGlobalDownload(id, resource, retryAttempts, path, filename);
|
||||
} else {
|
||||
downloadResource({
|
||||
service: resource?.service,
|
||||
name: resource?.name,
|
||||
identifier: resource?.identifier,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
resource?.identifier,
|
||||
resource?.name,
|
||||
resource?.service,
|
||||
downloadResource,
|
||||
isGlobal,
|
||||
retryAttempts,
|
||||
path,
|
||||
filename,
|
||||
]);
|
||||
|
||||
const resourceUrl = resource ? `/arbitrary/${resource.service}/${resource.name}/${resource.identifier}` : null;
|
||||
const resourceUrl = resource
|
||||
? `/arbitrary/${resource.service}/${resource.name}/${resource.identifier}`
|
||||
: null;
|
||||
|
||||
return useMemo(() => ({
|
||||
status: status?.status || "SEARCHING",
|
||||
localChunkCount: status?.localChunkCount || 0,
|
||||
totalChunkCount: status?.totalChunkCount || 0,
|
||||
percentLoaded: status?.percentLoaded || 0,
|
||||
isReady: status?.status === 'READY',
|
||||
resourceUrl,
|
||||
}), [status?.status, status?.localChunkCount, status?.totalChunkCount, status?.percentLoaded, resourceUrl]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
status: status?.status || "INITIAL",
|
||||
localChunkCount: status?.localChunkCount || 0,
|
||||
totalChunkCount: status?.totalChunkCount || 0,
|
||||
percentLoaded: status?.percentLoaded || 0,
|
||||
isReady: status?.status === "READY",
|
||||
resourceUrl,
|
||||
downloadResource: handledownloadResource,
|
||||
}),
|
||||
[
|
||||
status?.status,
|
||||
status?.localChunkCount,
|
||||
status?.totalChunkCount,
|
||||
status?.percentLoaded,
|
||||
resourceUrl,
|
||||
downloadResource,
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useMemo } from "react";
|
||||
import {
|
||||
QortalGetMetadata,
|
||||
QortalMetadata,
|
||||
QortalPreloadedParams,
|
||||
QortalSearchParams,
|
||||
} from "../types/interfaces/resources";
|
||||
import { ListItem, useCacheStore } from "../state/cache";
|
||||
@@ -10,6 +11,7 @@ import { base64ToUint8Array, uint8ArrayToObject } from "../utils/base64";
|
||||
import { retryTransaction } from "../utils/publish";
|
||||
import { ReturnType } from "../components/ResourceList/ResourceListDisplay";
|
||||
import { useListStore } from "../state/lists";
|
||||
import { usePublishStore } from "../state/publishes";
|
||||
|
||||
export const requestQueueProductPublishes = new RequestQueueWithPromise(20);
|
||||
export const requestQueueProductPublishesBackup = new RequestQueueWithPromise(
|
||||
@@ -20,16 +22,24 @@ export interface Resource {
|
||||
qortalMetadata: QortalMetadata;
|
||||
data: any;
|
||||
}
|
||||
export const useResources = (retryAttempts: number = 2) => {
|
||||
export const useResources = (retryAttempts: number = 2, maxSize = 5242880) => {
|
||||
const setSearchCache = useCacheStore((s) => s.setSearchCache);
|
||||
const deleteSearchCache = useCacheStore((s)=> s.deleteSearchCache)
|
||||
const getSearchCache = useCacheStore((s) => s.getSearchCache);
|
||||
const getResourceCache = useCacheStore((s) => s.getResourceCache);
|
||||
const setResourceCache = useCacheStore((s) => s.setResourceCache);
|
||||
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 setPublish = usePublishStore((state)=> state.setPublish)
|
||||
|
||||
const deleteList = useListStore(state => state.deleteList)
|
||||
const deleteListInStore = useListStore(state => state.deleteList)
|
||||
|
||||
const deleteList = useCallback((listName: string)=> {
|
||||
deleteListInStore(listName)
|
||||
deleteSearchCache(listName)
|
||||
}, [])
|
||||
const requestControllers = new Map<string, AbortController>();
|
||||
|
||||
const getArbitraryResource = async (
|
||||
@@ -176,6 +186,7 @@ export const useResources = (retryAttempts: number = 2) => {
|
||||
`${item?.service}-${item?.name}-${item?.identifier}`,
|
||||
fullDataObject
|
||||
);
|
||||
setPublish(fullDataObject?.qortalMetadata, fullDataObject);
|
||||
return fullDataObject;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -204,7 +215,6 @@ export const useResources = (retryAttempts: number = 2) => {
|
||||
if (cancelRequests) {
|
||||
cancelAllRequests();
|
||||
}
|
||||
|
||||
const cacheKey = generateCacheKey(params);
|
||||
const searchCache = getSearchCache(listName, cacheKey);
|
||||
if (searchCache) {
|
||||
@@ -213,6 +223,7 @@ export const useResources = (retryAttempts: number = 2) => {
|
||||
delete copyParams.before
|
||||
delete copyParams.offset
|
||||
setSearchParamsForList(listName, JSON.stringify(copyParams))
|
||||
fetchDataFromResults(searchCache, returnType);
|
||||
return searchCache;
|
||||
}
|
||||
|
||||
@@ -220,30 +231,33 @@ export const useResources = (retryAttempts: number = 2) => {
|
||||
let filteredResults: QortalMetadata[] = [];
|
||||
let lastCreated = params.before || undefined;
|
||||
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) {
|
||||
const response = await qortalRequest({
|
||||
action: "SEARCH_QDN_RESOURCES",
|
||||
mode: "ALL",
|
||||
...params,
|
||||
limit: targetLimit - filteredResults.length, // Adjust limit dynamically
|
||||
before: lastCreated,
|
||||
excludeBlocked: true
|
||||
});
|
||||
|
||||
if (!response || response.length === 0) {
|
||||
break; // No more data available
|
||||
}
|
||||
|
||||
responseData = response;
|
||||
const validResults = responseData.filter((item) => item.size !== 32);
|
||||
const validResults = responseData.filter((item) => item.size !== 32 && item.size < maxSize);
|
||||
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}
|
||||
@@ -258,6 +272,49 @@ export const useResources = (retryAttempts: number = 2) => {
|
||||
[getSearchCache, setSearchCache, fetchDataFromResults]
|
||||
);
|
||||
|
||||
const fetchPreloadedResources = useCallback(
|
||||
async (
|
||||
params: QortalPreloadedParams,
|
||||
listOfResources: QortalMetadata[],
|
||||
listName: string,
|
||||
returnType: ReturnType = 'JSON',
|
||||
cancelRequests?: boolean,
|
||||
): Promise<QortalMetadata[]> => {
|
||||
if (cancelRequests) {
|
||||
cancelAllRequests();
|
||||
}
|
||||
|
||||
// const cacheKey = generateCacheKey(params);
|
||||
const cacheKey = generatePreloadedCacheKey(params);
|
||||
const searchCache = getSearchCache(listName, cacheKey);
|
||||
if (searchCache) {
|
||||
const copyParams = {...params}
|
||||
|
||||
setSearchParamsForList(listName, JSON.stringify(copyParams))
|
||||
fetchDataFromResults(searchCache, returnType);
|
||||
return searchCache;
|
||||
}
|
||||
|
||||
let responseData: QortalMetadata[] = [];
|
||||
let filteredResults: QortalMetadata[] = [];
|
||||
const targetLimit = params.offset || 20; // Use `params.limit` if provided, else default to 20
|
||||
const isUnlimited = params.limit === 0;
|
||||
|
||||
if(isUnlimited){
|
||||
filteredResults = listOfResources
|
||||
} else {
|
||||
filteredResults = listOfResources?.slice(0, targetLimit)
|
||||
}
|
||||
const copyParams = {...params}
|
||||
|
||||
setSearchCache(listName, cacheKey, filteredResults, cancelRequests ? JSON.stringify(copyParams) : null);
|
||||
fetchDataFromResults(filteredResults, returnType);
|
||||
|
||||
return filteredResults;
|
||||
},
|
||||
[getSearchCache, setSearchCache, fetchDataFromResults]
|
||||
);
|
||||
|
||||
const fetchResourcesResultsOnly = useCallback(
|
||||
async (
|
||||
params: QortalSearchParams
|
||||
@@ -266,15 +323,17 @@ export const useResources = (retryAttempts: number = 2) => {
|
||||
let responseData: QortalMetadata[] = [];
|
||||
let filteredResults: QortalMetadata[] = [];
|
||||
let lastCreated = params.before || undefined;
|
||||
const targetLimit = params.limit ?? 20;
|
||||
|
||||
while (filteredResults.length < targetLimit) {
|
||||
const targetLimit = params.limit ?? 20; // Use `params.limit` if provided, else default to 20
|
||||
const isUnlimited = params.limit === 0;
|
||||
while (isUnlimited || filteredResults.length < targetLimit) {
|
||||
|
||||
const response = await qortalRequest({
|
||||
action: "SEARCH_QDN_RESOURCES",
|
||||
mode: "ALL",
|
||||
...params,
|
||||
limit: targetLimit - filteredResults.length,
|
||||
before: lastCreated,
|
||||
excludeBlocked: true
|
||||
});
|
||||
|
||||
if (!response || response.length === 0) break;
|
||||
@@ -283,12 +342,13 @@ export const useResources = (retryAttempts: number = 2) => {
|
||||
const validResults = responseData.filter((item) => item.size !== 32);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -300,7 +360,6 @@ export const useResources = (retryAttempts: number = 2) => {
|
||||
|
||||
const addNewResources = useCallback(
|
||||
(listName: string, resources: Resource[]) => {
|
||||
|
||||
addTemporaryResource(
|
||||
listName,
|
||||
resources.map((item) => item.qortalMetadata)
|
||||
@@ -310,6 +369,7 @@ export const useResources = (retryAttempts: number = 2) => {
|
||||
`${temporaryResource?.qortalMetadata?.service}-${temporaryResource?.qortalMetadata?.name}-${temporaryResource?.qortalMetadata?.identifier}`,
|
||||
temporaryResource
|
||||
);
|
||||
setPublish(temporaryResource?.qortalMetadata, temporaryResource);
|
||||
});
|
||||
},
|
||||
[]
|
||||
@@ -321,6 +381,7 @@ export const useResources = (retryAttempts: number = 2) => {
|
||||
`${temporaryResource?.qortalMetadata?.service}-${temporaryResource?.qortalMetadata?.name}-${temporaryResource?.qortalMetadata?.identifier}`,
|
||||
temporaryResource
|
||||
);
|
||||
setPublish(temporaryResource?.qortalMetadata, temporaryResource);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -345,11 +406,11 @@ export const useResources = (retryAttempts: number = 2) => {
|
||||
}, 600000);
|
||||
resourcesToDelete.forEach((item)=> {
|
||||
markResourceAsDeleted(item);
|
||||
setPublish(item, null);
|
||||
})
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
return useMemo(() => ({
|
||||
fetchResources,
|
||||
@@ -357,8 +418,10 @@ export const useResources = (retryAttempts: number = 2) => {
|
||||
updateNewResources,
|
||||
deleteResource,
|
||||
deleteList,
|
||||
fetchResourcesResultsOnly
|
||||
}), [fetchResources, addNewResources, updateNewResources, deleteResource, deleteList, fetchResourcesResultsOnly]);
|
||||
addList,
|
||||
fetchResourcesResultsOnly,
|
||||
fetchPreloadedResources
|
||||
}), [fetchResources, addNewResources, updateNewResources, deleteResource, deleteList, fetchResourcesResultsOnly, addList, fetchPreloadedResources]);
|
||||
|
||||
};
|
||||
|
||||
@@ -413,3 +476,21 @@ export const generateCacheKey = (params: QortalSearchParams): string => {
|
||||
|
||||
return keyParts;
|
||||
};
|
||||
|
||||
|
||||
export const generatePreloadedCacheKey = (params: QortalPreloadedParams): string => {
|
||||
const {
|
||||
limit,
|
||||
offset
|
||||
} = params;
|
||||
|
||||
const keyParts = [
|
||||
|
||||
limit !== undefined && `l-${limit}`,
|
||||
offset !== undefined && `o-${offset}`,
|
||||
]
|
||||
.filter(Boolean) // Remove undefined or empty values
|
||||
.join("_"); // Join into a string
|
||||
|
||||
return keyParts;
|
||||
};
|
||||
|
||||
654
src/i18n/compiled-i18n.json
Normal file
654
src/i18n/compiled-i18n.json
Normal file
@@ -0,0 +1,654 @@
|
||||
{
|
||||
"resources": {
|
||||
"ar": {
|
||||
"lib-core": {
|
||||
"subtitle": {
|
||||
"subtitles": "الترجمات",
|
||||
"no_subtitles": "لا توجد ترجمات",
|
||||
"load_community_subs": "تحميل ترجمات المجتمع",
|
||||
"off": "إيقاف",
|
||||
"deleting_subtitle": "جارٍ حذف الترجمة...",
|
||||
"deleted": "تم حذف الترجمة",
|
||||
"unable_delete": "تعذّر الحذف",
|
||||
"publishing": "جارٍ نشر الترجمات...",
|
||||
"published": "تم نشر الترجمات",
|
||||
"unable_publish": "تعذّر النشر",
|
||||
"my_subtitles": "ترجماتي",
|
||||
"new": "جديد",
|
||||
"existing": "موجود",
|
||||
"import_subtitles": "استيراد ترجمات"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "إزالة",
|
||||
"publish": "نشر",
|
||||
"delete": "حذف",
|
||||
"publish_metadata": "نشر البيانات الوصفية",
|
||||
"publish_index": "نشر الفهرس",
|
||||
"cancel": "إلغاء",
|
||||
"continue": "متابعة",
|
||||
"close": "إغلاق",
|
||||
"retry": "إعادة المحاولة"
|
||||
},
|
||||
"video": {
|
||||
"playback_speed": "سرعة التشغيل",
|
||||
"toggle_fullscreen": "تبديل ملء الشاشة (F)",
|
||||
"video_speed": "سرعة الفيديو. زيادة (+ أو >)، نقصان (- أو <)",
|
||||
"toggle_mute": "تبديل كتم الصوت (M)، رفع الصوت (أعلى)، خفض الصوت (أسفل)",
|
||||
"seek_video": "تقديم الفيديو بنسبة 10٪ (0-9)",
|
||||
"reload_video": "إعادة تحميل الفيديو (R)",
|
||||
"play_pause": "تشغيل/إيقاف مؤقت (المسطرة)"
|
||||
},
|
||||
"index": {
|
||||
"title": "مدير الفهارس",
|
||||
"create_new_index": "إنشاء فهرس جديد",
|
||||
"add_metadata": "إضافة بيانات وصفية",
|
||||
"publishing_metadata": "جارٍ نشر البيانات الوصفية...",
|
||||
"published_metadata": "تم نشر البيانات الوصفية بنجاح",
|
||||
"failed_metadata": "فشل في نشر البيانات الوصفية",
|
||||
"example": "مثال على الشكل المتوقع:",
|
||||
"metadata_title": "العنوان",
|
||||
"metadata_description": "الوصف",
|
||||
"metadata_title_placeholder": "أضف عنوانًا للرابط",
|
||||
"metadata_description_placeholder": "أضف وصفًا للرابط",
|
||||
"characters": "حروف",
|
||||
"publishing_index": "جارٍ نشر الفهرس...",
|
||||
"published_index": "تم نشر الفهرس بنجاح",
|
||||
"failed_index": "فشل في نشر الفهرس",
|
||||
"recommended_indices": "الفهارس الموصى بها",
|
||||
"add_search_term": "إضافة مصطلح بحث",
|
||||
"search_terms": "مصطلحات البحث",
|
||||
"recommendation_size": "يُنصح بأن يكون عدد حروف المصطلح أقل من {{recommendedSize}} حرفًا",
|
||||
"multiple_title": "إضافة فهارس متعددة",
|
||||
"multiple_description": "الفهارس اللاحقة ستقلل من رسوم النشر، لكنها ستكون أقل تأثيرًا في نتائج البحث المستقبلية."
|
||||
},
|
||||
"multi_publish": {
|
||||
"title": "حالة النشر",
|
||||
"publish_failed": "فشل النشر",
|
||||
"file_chunk": "جزء من الملف",
|
||||
"file_processing": "جارٍ معالجة الملف",
|
||||
"success": "تم النشر بنجاح",
|
||||
"attempt_retry": "فشل النشر. تتم المحاولة مرة أخرى..."
|
||||
}
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"lib-core": {
|
||||
"subtitle": {
|
||||
"subtitles": "Subtitles",
|
||||
"no_subtitles": "No subtitles",
|
||||
"load_community_subs": "Load community subtitles",
|
||||
"off": "Off",
|
||||
"deleting_subtitle": "Deleting subtitle...",
|
||||
"deleted": "Deleted subtitle",
|
||||
"unable_delete": "unable to delete",
|
||||
"publishing": "Publishing subtitles...",
|
||||
"published": "Subtitles published",
|
||||
"unable_publish": "Unable to publish",
|
||||
"my_subtitles": "My Subtitles",
|
||||
"new": "New",
|
||||
"existing": "Existing",
|
||||
"import_subtitles": "Import subtitles"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "remove",
|
||||
"publish": "publish",
|
||||
"delete": "delete",
|
||||
"publish_metadata": "publish metadata",
|
||||
"publish_index": "publish index",
|
||||
"cancel": "cancel",
|
||||
"continue": "continue",
|
||||
"close": "close",
|
||||
"retry": "retry"
|
||||
},
|
||||
"video": {
|
||||
"playback_speed": "Playback speed",
|
||||
"toggle_fullscreen": "Toggle Fullscreen (F)",
|
||||
"video_speed": "Video Speed. Increase (+ or >), Decrease (- or <)",
|
||||
"toggle_mute": "Toggle Mute (M), Raise (UP), Lower (DOWN)",
|
||||
"seek_video": "Seek video in 10% increments (0-9)",
|
||||
"reload_video": "Reload Video (R)",
|
||||
"play_pause": "Pause/Play (Spacebar)"
|
||||
},
|
||||
"index": {
|
||||
"title": "Index Manager",
|
||||
"create_new_index": "Create new index",
|
||||
"add_metadata": "Add metadata",
|
||||
"publishing_metadata": "Publishing metadata...",
|
||||
"published_metadata": "Successfully published metadata",
|
||||
"failed_metadata": "Failed to publish metadata",
|
||||
"example": "Example of how it could look like:",
|
||||
"metadata_title": "Title",
|
||||
"metadata_description": "Description",
|
||||
"metadata_title_placeholder": "Add a title for the link",
|
||||
"metadata_description_placeholder": "Add a description for the link",
|
||||
"characters": "characters",
|
||||
"publishing_index": "Publishing index...",
|
||||
"published_index": "Successfully published index",
|
||||
"failed_index": "Failed to publish index",
|
||||
"recommended_indices": "Recommended Indices",
|
||||
"add_search_term": "Add search term",
|
||||
"search_terms": "search terms",
|
||||
"recommendation_size": "It is recommended to keep your term character count below {{recommendedSize}} characters",
|
||||
"multiple_title": "Adding multiple indices",
|
||||
"multiple_description": "Subsequent indices will keep your publish fees lower, but they will have less strength in future search results."
|
||||
},
|
||||
"multi_publish": {
|
||||
"title": "Publishing Status",
|
||||
"publish_failed": "Publish Failed",
|
||||
"file_chunk": "File Chunk",
|
||||
"file_processing": "File Processing",
|
||||
"success": "Published successfully",
|
||||
"attempt_retry": "Publish failed. Attempting retry..."
|
||||
}
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"lib-core": {
|
||||
"subtitle": {
|
||||
"subtitles": "Untertitel",
|
||||
"no_subtitles": "Keine Untertitel",
|
||||
"load_community_subs": "Community-Untertitel laden",
|
||||
"off": "Aus",
|
||||
"deleting_subtitle": "Untertitel wird gelöscht...",
|
||||
"deleted": "Untertitel gelöscht",
|
||||
"unable_delete": "Löschen nicht möglich",
|
||||
"publishing": "Untertitel werden veröffentlicht...",
|
||||
"published": "Untertitel veröffentlicht",
|
||||
"unable_publish": "Veröffentlichung nicht möglich",
|
||||
"my_subtitles": "Meine Untertitel",
|
||||
"new": "Neu",
|
||||
"existing": "Vorhanden",
|
||||
"import_subtitles": "Untertitel importieren"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "entfernen",
|
||||
"publish": "veröffentlichen",
|
||||
"delete": "löschen",
|
||||
"publish_metadata": "Metadaten veröffentlichen",
|
||||
"publish_index": "Index veröffentlichen",
|
||||
"cancel": "abbrechen",
|
||||
"continue": "fortfahren",
|
||||
"close": "schließen",
|
||||
"retry": "wiederholen"
|
||||
},
|
||||
"video": {
|
||||
"playback_speed": "Wiedergabegeschwindigkeit",
|
||||
"toggle_fullscreen": "Vollbild umschalten (F)",
|
||||
"video_speed": "Video-Geschwindigkeit. Erhöhen (+ oder >), Verringern (- oder <)",
|
||||
"toggle_mute": "Stummschalten umschalten (M), Lauter (OBEN), Leiser (UNTEN)",
|
||||
"seek_video": "Video in 10%-Schritten vorspulen (0–9)",
|
||||
"reload_video": "Video neu laden (R)",
|
||||
"play_pause": "Wiedergabe/Pause (Leertaste)"
|
||||
},
|
||||
"index": {
|
||||
"title": "Index-Manager",
|
||||
"create_new_index": "Neuen Index erstellen",
|
||||
"add_metadata": "Metadaten hinzufügen",
|
||||
"publishing_metadata": "Metadaten werden veröffentlicht...",
|
||||
"published_metadata": "Metadaten erfolgreich veröffentlicht",
|
||||
"failed_metadata": "Veröffentlichung der Metadaten fehlgeschlagen",
|
||||
"example": "Beispiel, wie es aussehen könnte:",
|
||||
"metadata_title": "Titel",
|
||||
"metadata_description": "Beschreibung",
|
||||
"metadata_title_placeholder": "Titel für den Link hinzufügen",
|
||||
"metadata_description_placeholder": "Beschreibung für den Link hinzufügen",
|
||||
"characters": "Zeichen",
|
||||
"publishing_index": "Index wird veröffentlicht...",
|
||||
"published_index": "Index erfolgreich veröffentlicht",
|
||||
"failed_index": "Veröffentlichung des Index fehlgeschlagen",
|
||||
"recommended_indices": "Empfohlene Indizes",
|
||||
"add_search_term": "Suchbegriff hinzufügen",
|
||||
"search_terms": "Suchbegriffe",
|
||||
"recommendation_size": "Es wird empfohlen, die Zeichenanzahl pro Begriff unter {{recommendedSize}} Zeichen zu halten",
|
||||
"multiple_title": "Mehrere Indizes hinzufügen",
|
||||
"multiple_description": "Weitere Indizes senken die Veröffentlichungsgebühren, haben aber weniger Gewicht in zukünftigen Suchergebnissen."
|
||||
},
|
||||
"multi_publish": {
|
||||
"title": "Veröffentlichungsstatus",
|
||||
"publish_failed": "Veröffentlichung fehlgeschlagen",
|
||||
"file_chunk": "Dateifragment",
|
||||
"file_processing": "Datei wird verarbeitet",
|
||||
"success": "Erfolgreich veröffentlicht",
|
||||
"attempt_retry": "Veröffentlichung fehlgeschlagen. Erneuter Versuch..."
|
||||
}
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"lib-core": {
|
||||
"subtitle": {
|
||||
"subtitles": "Subtítulos",
|
||||
"no_subtitles": "Sin subtítulos",
|
||||
"load_community_subs": "Cargar subtítulos de la comunidad",
|
||||
"off": "Desactivado",
|
||||
"deleting_subtitle": "Eliminando subtítulo...",
|
||||
"deleted": "Subtítulo eliminado",
|
||||
"unable_delete": "No se pudo eliminar",
|
||||
"publishing": "Publicando subtítulos...",
|
||||
"published": "Subtítulos publicados",
|
||||
"unable_publish": "No se pudo publicar",
|
||||
"my_subtitles": "Mis subtítulos",
|
||||
"new": "Nuevo",
|
||||
"existing": "Existente",
|
||||
"import_subtitles": "Importar subtítulos"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "eliminar",
|
||||
"publish": "publicar",
|
||||
"delete": "borrar",
|
||||
"publish_metadata": "publicar metadatos",
|
||||
"publish_index": "publicar índice",
|
||||
"cancel": "cancelar",
|
||||
"continue": "continuar",
|
||||
"close": "cerrar",
|
||||
"retry": "reintentar"
|
||||
},
|
||||
"video": {
|
||||
"playback_speed": "Velocidad de reproducción",
|
||||
"toggle_fullscreen": "Alternar pantalla completa (F)",
|
||||
"video_speed": "Velocidad del video. Aumentar (+ o >), Disminuir (- o <)",
|
||||
"toggle_mute": "Alternar silencio (M), Subir volumen (ARRIBA), Bajar volumen (ABAJO)",
|
||||
"seek_video": "Buscar en el video en incrementos del 10% (0-9)",
|
||||
"reload_video": "Recargar video (R)",
|
||||
"play_pause": "Pausar/Reproducir (Barra espaciadora)"
|
||||
},
|
||||
"index": {
|
||||
"title": "Gestor de Índices",
|
||||
"create_new_index": "Crear nuevo índice",
|
||||
"add_metadata": "Agregar metadatos",
|
||||
"publishing_metadata": "Publicando metadatos...",
|
||||
"published_metadata": "Metadatos publicados correctamente",
|
||||
"failed_metadata": "Error al publicar metadatos",
|
||||
"example": "Ejemplo de cómo podría verse:",
|
||||
"metadata_title": "Título",
|
||||
"metadata_description": "Descripción",
|
||||
"metadata_title_placeholder": "Agregar un título para el enlace",
|
||||
"metadata_description_placeholder": "Agregar una descripción para el enlace",
|
||||
"characters": "caracteres",
|
||||
"publishing_index": "Publicando índice...",
|
||||
"published_index": "Índice publicado correctamente",
|
||||
"failed_index": "Error al publicar el índice",
|
||||
"recommended_indices": "Índices recomendados",
|
||||
"add_search_term": "Agregar término de búsqueda",
|
||||
"search_terms": "términos de búsqueda",
|
||||
"recommendation_size": "Se recomienda mantener el número de caracteres por término por debajo de {{recommendedSize}} caracteres",
|
||||
"multiple_title": "Agregando múltiples índices",
|
||||
"multiple_description": "Los índices subsiguientes mantendrán más bajos los costos de publicación, pero tendrán menos peso en los resultados de búsqueda futuros."
|
||||
},
|
||||
"multi_publish": {
|
||||
"title": "Estado de Publicación",
|
||||
"publish_failed": "Fallo en la publicación",
|
||||
"file_chunk": "Fragmento de archivo",
|
||||
"file_processing": "Procesando archivo",
|
||||
"success": "Publicado con éxito",
|
||||
"attempt_retry": "Error en la publicación. Intentando reintentar..."
|
||||
}
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"lib-core": {
|
||||
"subtitle": {
|
||||
"subtitles": "Sous-titres",
|
||||
"no_subtitles": "Aucun sous-titre",
|
||||
"load_community_subs": "Charger les sous-titres de la communauté",
|
||||
"off": "Désactivé",
|
||||
"deleting_subtitle": "Suppression du sous-titre...",
|
||||
"deleted": "Sous-titre supprimé",
|
||||
"unable_delete": "Impossible de supprimer",
|
||||
"publishing": "Publication des sous-titres...",
|
||||
"published": "Sous-titres publiés",
|
||||
"unable_publish": "Impossible de publier",
|
||||
"my_subtitles": "Mes sous-titres",
|
||||
"new": "Nouveau",
|
||||
"existing": "Existant",
|
||||
"import_subtitles": "Importer des sous-titres"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "supprimer",
|
||||
"publish": "publier",
|
||||
"delete": "effacer",
|
||||
"publish_metadata": "publier les métadonnées",
|
||||
"publish_index": "publier l’index",
|
||||
"cancel": "annuler",
|
||||
"continue": "continuer",
|
||||
"close": "fermer",
|
||||
"retry": "réessayer"
|
||||
},
|
||||
"video": {
|
||||
"playback_speed": "Vitesse de lecture",
|
||||
"toggle_fullscreen": "Plein écran (F)",
|
||||
"video_speed": "Vitesse de la vidéo. Augmenter (+ ou >), Diminuer (- ou <)",
|
||||
"toggle_mute": "Activer/désactiver le son (M), Monter (HAUT), Baisser (BAS)",
|
||||
"seek_video": "Avancer dans la vidéo par incréments de 10 % (0–9)",
|
||||
"reload_video": "Recharger la vidéo (R)",
|
||||
"play_pause": "Lecture/Pause (Barre d’espace)"
|
||||
},
|
||||
"index": {
|
||||
"title": "Gestionnaire d’index",
|
||||
"create_new_index": "Créer un nouvel index",
|
||||
"add_metadata": "Ajouter des métadonnées",
|
||||
"publishing_metadata": "Publication des métadonnées...",
|
||||
"published_metadata": "Métadonnées publiées avec succès",
|
||||
"failed_metadata": "Échec de la publication des métadonnées",
|
||||
"example": "Exemple de présentation :",
|
||||
"metadata_title": "Titre",
|
||||
"metadata_description": "Description",
|
||||
"metadata_title_placeholder": "Ajouter un titre pour le lien",
|
||||
"metadata_description_placeholder": "Ajouter une description pour le lien",
|
||||
"characters": "caractères",
|
||||
"publishing_index": "Publication de l’index...",
|
||||
"published_index": "Index publié avec succès",
|
||||
"failed_index": "Échec de la publication de l’index",
|
||||
"recommended_indices": "Index recommandés",
|
||||
"add_search_term": "Ajouter un terme de recherche",
|
||||
"search_terms": "termes de recherche",
|
||||
"recommendation_size": "Il est recommandé de limiter chaque terme à moins de {{recommendedSize}} caractères",
|
||||
"multiple_title": "Ajout d’index multiples",
|
||||
"multiple_description": "Les index supplémentaires réduisent les frais de publication, mais auront moins d’impact dans les résultats de recherche futurs."
|
||||
},
|
||||
"multi_publish": {
|
||||
"title": "Statut de publication",
|
||||
"publish_failed": "Échec de la publication",
|
||||
"file_chunk": "Morceau de fichier",
|
||||
"file_processing": "Traitement du fichier",
|
||||
"success": "Publié avec succès",
|
||||
"attempt_retry": "Échec de la publication. Nouvelle tentative..."
|
||||
}
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"lib-core": {
|
||||
"subtitle": {
|
||||
"subtitles": "Sottotitoli",
|
||||
"no_subtitles": "Nessun sottotitolo",
|
||||
"load_community_subs": "Carica sottotitoli della comunità",
|
||||
"off": "Disattivato",
|
||||
"deleting_subtitle": "Eliminazione sottotitolo in corso...",
|
||||
"deleted": "Sottotitolo eliminato",
|
||||
"unable_delete": "Impossibile eliminare",
|
||||
"publishing": "Pubblicazione dei sottotitoli in corso...",
|
||||
"published": "Sottotitoli pubblicati",
|
||||
"unable_publish": "Impossibile pubblicare",
|
||||
"my_subtitles": "I miei sottotitoli",
|
||||
"new": "Nuovo",
|
||||
"existing": "Esistente",
|
||||
"import_subtitles": "Importa sottotitoli"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "rimuovi",
|
||||
"publish": "pubblica",
|
||||
"delete": "elimina",
|
||||
"publish_metadata": "pubblica metadati",
|
||||
"publish_index": "pubblica indice",
|
||||
"cancel": "annulla",
|
||||
"continue": "continua",
|
||||
"close": "chiudi",
|
||||
"retry": "ripeti"
|
||||
},
|
||||
"video": {
|
||||
"playback_speed": "Velocità di riproduzione",
|
||||
"toggle_fullscreen": "Attiva/disattiva schermo intero (F)",
|
||||
"video_speed": "Velocità video. Aumenta (+ o >), Diminuisci (- o <)",
|
||||
"toggle_mute": "Attiva/disattiva audio (M), Alza volume (SU), Abbassa volume (GIÙ)",
|
||||
"seek_video": "Vai avanti nel video a intervalli del 10% (0–9)",
|
||||
"reload_video": "Ricarica video (R)",
|
||||
"play_pause": "Riproduci/Pausa (Barra spaziatrice)"
|
||||
},
|
||||
"index": {
|
||||
"title": "Gestore degli Indici",
|
||||
"create_new_index": "Crea nuovo indice",
|
||||
"add_metadata": "Aggiungi metadati",
|
||||
"publishing_metadata": "Pubblicazione metadati in corso...",
|
||||
"published_metadata": "Metadati pubblicati con successo",
|
||||
"failed_metadata": "Errore nella pubblicazione dei metadati",
|
||||
"example": "Esempio di come potrebbe apparire:",
|
||||
"metadata_title": "Titolo",
|
||||
"metadata_description": "Descrizione",
|
||||
"metadata_title_placeholder": "Aggiungi un titolo per il link",
|
||||
"metadata_description_placeholder": "Aggiungi una descrizione per il link",
|
||||
"characters": "caratteri",
|
||||
"publishing_index": "Pubblicazione indice in corso...",
|
||||
"published_index": "Indice pubblicato con successo",
|
||||
"failed_index": "Errore nella pubblicazione dell'indice",
|
||||
"recommended_indices": "Indici consigliati",
|
||||
"add_search_term": "Aggiungi termine di ricerca",
|
||||
"search_terms": "termini di ricerca",
|
||||
"recommendation_size": "Si consiglia di mantenere il numero di caratteri per termine inferiore a {{recommendedSize}} caratteri",
|
||||
"multiple_title": "Aggiunta di indici multipli",
|
||||
"multiple_description": "Indici successivi ridurranno le commissioni di pubblicazione, ma avranno meno rilevanza nei risultati di ricerca futuri."
|
||||
},
|
||||
"multi_publish": {
|
||||
"title": "Stato della pubblicazione",
|
||||
"publish_failed": "Pubblicazione fallita",
|
||||
"file_chunk": "Frammento di file",
|
||||
"file_processing": "Elaborazione file in corso",
|
||||
"success": "Pubblicato con successo",
|
||||
"attempt_retry": "Pubblicazione fallita. Tentativo di nuovo..."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"lib-core": {
|
||||
"subtitle": {
|
||||
"subtitles": "字幕",
|
||||
"no_subtitles": "字幕なし",
|
||||
"load_community_subs": "コミュニティ字幕を読み込む",
|
||||
"off": "オフ",
|
||||
"deleting_subtitle": "字幕を削除中...",
|
||||
"deleted": "字幕を削除しました",
|
||||
"unable_delete": "削除できませんでした",
|
||||
"publishing": "字幕を公開中...",
|
||||
"published": "字幕を公開しました",
|
||||
"unable_publish": "公開できませんでした",
|
||||
"my_subtitles": "自分の字幕",
|
||||
"new": "新規",
|
||||
"existing": "既存",
|
||||
"import_subtitles": "字幕をインポート"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "削除する",
|
||||
"publish": "公開する",
|
||||
"delete": "削除",
|
||||
"publish_metadata": "メタデータを公開",
|
||||
"publish_index": "インデックスを公開",
|
||||
"cancel": "キャンセル",
|
||||
"continue": "続行",
|
||||
"close": "閉じる",
|
||||
"retry": "再試行"
|
||||
},
|
||||
"video": {
|
||||
"playback_speed": "再生速度",
|
||||
"toggle_fullscreen": "フルスクリーン切替 (F)",
|
||||
"video_speed": "動画速度:速くする(+ または >)、遅くする(- または <)",
|
||||
"toggle_mute": "ミュート切替 (M)、音量を上げる(↑)、下げる(↓)",
|
||||
"seek_video": "動画を10%ずつシーク(0〜9)",
|
||||
"reload_video": "動画を再読み込み (R)",
|
||||
"play_pause": "再生/一時停止(スペースキー)"
|
||||
},
|
||||
"index": {
|
||||
"title": "インデックスマネージャー",
|
||||
"create_new_index": "新しいインデックスを作成",
|
||||
"add_metadata": "メタデータを追加",
|
||||
"publishing_metadata": "メタデータを公開中...",
|
||||
"published_metadata": "メタデータの公開に成功しました",
|
||||
"failed_metadata": "メタデータの公開に失敗しました",
|
||||
"example": "表示例:",
|
||||
"metadata_title": "タイトル",
|
||||
"metadata_description": "説明",
|
||||
"metadata_title_placeholder": "リンクのタイトルを入力",
|
||||
"metadata_description_placeholder": "リンクの説明を入力",
|
||||
"characters": "文字",
|
||||
"publishing_index": "インデックスを公開中...",
|
||||
"published_index": "インデックスを公開しました",
|
||||
"failed_index": "インデックスの公開に失敗しました",
|
||||
"recommended_indices": "おすすめのインデックス",
|
||||
"add_search_term": "検索語を追加",
|
||||
"search_terms": "検索語",
|
||||
"recommendation_size": "検索語は{{recommendedSize}}文字未満に抑えることを推奨します",
|
||||
"multiple_title": "複数のインデックスを追加",
|
||||
"multiple_description": "複数のインデックスにより公開コストを抑えられますが、検索結果での影響は小さくなります。"
|
||||
},
|
||||
"multi_publish": {
|
||||
"title": "公開状況",
|
||||
"publish_failed": "公開に失敗しました",
|
||||
"file_chunk": "ファイルチャンク",
|
||||
"file_processing": "ファイル処理中",
|
||||
"success": "公開に成功しました",
|
||||
"attempt_retry": "公開に失敗しました。再試行中..."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"lib-core": {
|
||||
"subtitle": {
|
||||
"subtitles": "Субтитры",
|
||||
"no_subtitles": "Нет субтитров",
|
||||
"load_community_subs": "Загрузить субтитры сообщества",
|
||||
"off": "Выключено",
|
||||
"deleting_subtitle": "Удаление субтитров...",
|
||||
"deleted": "Субтитры удалены",
|
||||
"unable_delete": "Не удалось удалить",
|
||||
"publishing": "Публикация субтитров...",
|
||||
"published": "Субтитры опубликованы",
|
||||
"unable_publish": "Не удалось опубликовать",
|
||||
"my_subtitles": "Мои субтитры",
|
||||
"new": "Новые",
|
||||
"existing": "Существующие",
|
||||
"import_subtitles": "Импорт субтитров"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "удалить",
|
||||
"publish": "опубликовать",
|
||||
"delete": "удалить",
|
||||
"publish_metadata": "опубликовать метаданные",
|
||||
"publish_index": "опубликовать индекс",
|
||||
"cancel": "отмена",
|
||||
"continue": "продолжить",
|
||||
"close": "закрыть",
|
||||
"retry": "повторить"
|
||||
},
|
||||
"video": {
|
||||
"playback_speed": "Скорость воспроизведения",
|
||||
"toggle_fullscreen": "Полный экран (F)",
|
||||
"video_speed": "Скорость видео. Увеличить (+ или >), Уменьшить (- или <)",
|
||||
"toggle_mute": "Вкл/выкл звук (M), Громче (ВВЕРХ), Тише (ВНИЗ)",
|
||||
"seek_video": "Переход по видео на 10% (0–9)",
|
||||
"reload_video": "Перезагрузить видео (R)",
|
||||
"play_pause": "Воспроизвести/Пауза (Пробел)"
|
||||
},
|
||||
"index": {
|
||||
"title": "Менеджер индексов",
|
||||
"create_new_index": "Создать новый индекс",
|
||||
"add_metadata": "Добавить метаданные",
|
||||
"publishing_metadata": "Публикация метаданных...",
|
||||
"published_metadata": "Метаданные успешно опубликованы",
|
||||
"failed_metadata": "Не удалось опубликовать метаданные",
|
||||
"example": "Пример того, как это может выглядеть:",
|
||||
"metadata_title": "Заголовок",
|
||||
"metadata_description": "Описание",
|
||||
"metadata_title_placeholder": "Добавьте заголовок для ссылки",
|
||||
"metadata_description_placeholder": "Добавьте описание для ссылки",
|
||||
"characters": "символов",
|
||||
"publishing_index": "Публикация индекса...",
|
||||
"published_index": "Индекс успешно опубликован",
|
||||
"failed_index": "Не удалось опубликовать индекс",
|
||||
"recommended_indices": "Рекомендуемые индексы",
|
||||
"add_search_term": "Добавить поисковый запрос",
|
||||
"search_terms": "поисковые запросы",
|
||||
"recommendation_size": "Рекомендуется использовать не более {{recommendedSize}} символов на запрос",
|
||||
"multiple_title": "Добавление нескольких индексов",
|
||||
"multiple_description": "Дополнительные индексы снижают стоимость публикации, но будут иметь меньший вес в результатах поиска в будущем."
|
||||
},
|
||||
"multi_publish": {
|
||||
"title": "Статус публикации",
|
||||
"publish_failed": "Ошибка публикации",
|
||||
"file_chunk": "Фрагмент файла",
|
||||
"file_processing": "Обработка файла",
|
||||
"success": "Успешно опубликовано",
|
||||
"attempt_retry": "Публикация не удалась. Повторная попытка..."
|
||||
}
|
||||
}
|
||||
},
|
||||
"zh": {
|
||||
"lib-core": {
|
||||
"subtitle": {
|
||||
"subtitles": "字幕",
|
||||
"no_subtitles": "无字幕",
|
||||
"load_community_subs": "加载社区字幕",
|
||||
"off": "关闭",
|
||||
"deleting_subtitle": "正在删除字幕...",
|
||||
"deleted": "字幕已删除",
|
||||
"unable_delete": "无法删除",
|
||||
"publishing": "正在发布字幕...",
|
||||
"published": "字幕已发布",
|
||||
"unable_publish": "无法发布",
|
||||
"my_subtitles": "我的字幕",
|
||||
"new": "新建",
|
||||
"existing": "已有",
|
||||
"import_subtitles": "导入字幕"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "移除",
|
||||
"publish": "发布",
|
||||
"delete": "删除",
|
||||
"publish_metadata": "发布元数据",
|
||||
"publish_index": "发布索引",
|
||||
"cancel": "取消",
|
||||
"continue": "继续",
|
||||
"close": "关闭",
|
||||
"retry": "重试"
|
||||
},
|
||||
"video": {
|
||||
"playback_speed": "播放速度",
|
||||
"toggle_fullscreen": "切换全屏 (F)",
|
||||
"video_speed": "视频速度。加快 (+ 或 >),减慢 (- 或 <)",
|
||||
"toggle_mute": "切换静音 (M),音量加 (↑),音量减 (↓)",
|
||||
"seek_video": "按 10% 增量跳转视频 (0–9)",
|
||||
"reload_video": "重新加载视频 (R)",
|
||||
"play_pause": "播放/暂停 (空格键)"
|
||||
},
|
||||
"index": {
|
||||
"title": "索引管理器",
|
||||
"create_new_index": "创建新索引",
|
||||
"add_metadata": "添加元数据",
|
||||
"publishing_metadata": "正在发布元数据...",
|
||||
"published_metadata": "元数据发布成功",
|
||||
"failed_metadata": "元数据发布失败",
|
||||
"example": "示例外观如下:",
|
||||
"metadata_title": "标题",
|
||||
"metadata_description": "描述",
|
||||
"metadata_title_placeholder": "为链接添加标题",
|
||||
"metadata_description_placeholder": "为链接添加描述",
|
||||
"characters": "字符",
|
||||
"publishing_index": "正在发布索引...",
|
||||
"published_index": "索引发布成功",
|
||||
"failed_index": "索引发布失败",
|
||||
"recommended_indices": "推荐索引",
|
||||
"add_search_term": "添加搜索词",
|
||||
"search_terms": "搜索词",
|
||||
"recommendation_size": "建议每个搜索词不超过 {{recommendedSize}} 个字符",
|
||||
"multiple_title": "添加多个索引",
|
||||
"multiple_description": "多个索引可以降低发布费用,但在未来搜索结果中的权重会较低。"
|
||||
},
|
||||
"multi_publish": {
|
||||
"title": "发布状态",
|
||||
"publish_failed": "发布失败",
|
||||
"file_chunk": "文件片段",
|
||||
"file_processing": "正在处理文件",
|
||||
"success": "发布成功",
|
||||
"attempt_retry": "发布失败。正在尝试重试..."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"supportedLanguages": [
|
||||
"ar",
|
||||
"en",
|
||||
"de",
|
||||
"es",
|
||||
"fr",
|
||||
"it",
|
||||
"ja",
|
||||
"ru",
|
||||
"zh"
|
||||
]
|
||||
}
|
||||
27
src/i18n/i18n.ts
Normal file
27
src/i18n/i18n.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createInstance } from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import compiled from './compiled-i18n.json';
|
||||
|
||||
export const supportedLanguages = compiled.supportedLanguages;
|
||||
|
||||
const libI18n = createInstance(); // ✅ this avoids conflict with consumer app
|
||||
|
||||
libI18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: compiled.resources,
|
||||
supportedLngs: compiled.supportedLanguages,
|
||||
fallbackLng: 'en',
|
||||
lng: typeof navigator !== 'undefined' ? navigator.language : 'en',
|
||||
defaultNS: 'lib-core',
|
||||
ns: ['lib-core'],
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
debug: false,
|
||||
});
|
||||
|
||||
export default libI18n;
|
||||
69
src/i18n/locales/ar/lib-core.json
Normal file
69
src/i18n/locales/ar/lib-core.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"subtitle": {
|
||||
"subtitles": "الترجمات",
|
||||
"no_subtitles": "لا توجد ترجمات",
|
||||
"load_community_subs": "تحميل ترجمات المجتمع",
|
||||
"off": "إيقاف",
|
||||
"deleting_subtitle": "جارٍ حذف الترجمة...",
|
||||
"deleted": "تم حذف الترجمة",
|
||||
"unable_delete": "تعذّر الحذف",
|
||||
"publishing": "جارٍ نشر الترجمات...",
|
||||
"published": "تم نشر الترجمات",
|
||||
"unable_publish": "تعذّر النشر",
|
||||
"my_subtitles": "ترجماتي",
|
||||
"new": "جديد",
|
||||
"existing": "موجود",
|
||||
"import_subtitles": "استيراد ترجمات"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "إزالة",
|
||||
"publish": "نشر",
|
||||
"delete": "حذف",
|
||||
"publish_metadata": "نشر البيانات الوصفية",
|
||||
"publish_index": "نشر الفهرس",
|
||||
"cancel": "إلغاء",
|
||||
"continue": "متابعة",
|
||||
"close": "إغلاق",
|
||||
"retry": "إعادة المحاولة"
|
||||
},
|
||||
"video": {
|
||||
"playback_speed": "سرعة التشغيل",
|
||||
"toggle_fullscreen": "تبديل ملء الشاشة (F)",
|
||||
"video_speed": "سرعة الفيديو. زيادة (+ أو >)، نقصان (- أو <)",
|
||||
"toggle_mute": "تبديل كتم الصوت (M)، رفع الصوت (أعلى)، خفض الصوت (أسفل)",
|
||||
"seek_video": "تقديم الفيديو بنسبة 10٪ (0-9)",
|
||||
"reload_video": "إعادة تحميل الفيديو (R)",
|
||||
"play_pause": "تشغيل/إيقاف مؤقت (المسطرة)"
|
||||
},
|
||||
"index": {
|
||||
"title": "مدير الفهارس",
|
||||
"create_new_index": "إنشاء فهرس جديد",
|
||||
"add_metadata": "إضافة بيانات وصفية",
|
||||
"publishing_metadata": "جارٍ نشر البيانات الوصفية...",
|
||||
"published_metadata": "تم نشر البيانات الوصفية بنجاح",
|
||||
"failed_metadata": "فشل في نشر البيانات الوصفية",
|
||||
"example": "مثال على الشكل المتوقع:",
|
||||
"metadata_title": "العنوان",
|
||||
"metadata_description": "الوصف",
|
||||
"metadata_title_placeholder": "أضف عنوانًا للرابط",
|
||||
"metadata_description_placeholder": "أضف وصفًا للرابط",
|
||||
"characters": "حروف",
|
||||
"publishing_index": "جارٍ نشر الفهرس...",
|
||||
"published_index": "تم نشر الفهرس بنجاح",
|
||||
"failed_index": "فشل في نشر الفهرس",
|
||||
"recommended_indices": "الفهارس الموصى بها",
|
||||
"add_search_term": "إضافة مصطلح بحث",
|
||||
"search_terms": "مصطلحات البحث",
|
||||
"recommendation_size": "يُنصح بأن يكون عدد حروف المصطلح أقل من {{recommendedSize}} حرفًا",
|
||||
"multiple_title": "إضافة فهارس متعددة",
|
||||
"multiple_description": "الفهارس اللاحقة ستقلل من رسوم النشر، لكنها ستكون أقل تأثيرًا في نتائج البحث المستقبلية."
|
||||
},
|
||||
"multi_publish": {
|
||||
"title": "حالة النشر",
|
||||
"publish_failed": "فشل النشر",
|
||||
"file_chunk": "جزء من الملف",
|
||||
"file_processing": "جارٍ معالجة الملف",
|
||||
"success": "تم النشر بنجاح",
|
||||
"attempt_retry": "فشل النشر. تتم المحاولة مرة أخرى..."
|
||||
}
|
||||
}
|
||||
69
src/i18n/locales/de/lib-core.json
Normal file
69
src/i18n/locales/de/lib-core.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"subtitle": {
|
||||
"subtitles": "Untertitel",
|
||||
"no_subtitles": "Keine Untertitel",
|
||||
"load_community_subs": "Community-Untertitel laden",
|
||||
"off": "Aus",
|
||||
"deleting_subtitle": "Untertitel wird gelöscht...",
|
||||
"deleted": "Untertitel gelöscht",
|
||||
"unable_delete": "Löschen nicht möglich",
|
||||
"publishing": "Untertitel werden veröffentlicht...",
|
||||
"published": "Untertitel veröffentlicht",
|
||||
"unable_publish": "Veröffentlichung nicht möglich",
|
||||
"my_subtitles": "Meine Untertitel",
|
||||
"new": "Neu",
|
||||
"existing": "Vorhanden",
|
||||
"import_subtitles": "Untertitel importieren"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "entfernen",
|
||||
"publish": "veröffentlichen",
|
||||
"delete": "löschen",
|
||||
"publish_metadata": "Metadaten veröffentlichen",
|
||||
"publish_index": "Index veröffentlichen",
|
||||
"cancel": "abbrechen",
|
||||
"continue": "fortfahren",
|
||||
"close": "schließen",
|
||||
"retry": "wiederholen"
|
||||
},
|
||||
"video": {
|
||||
"playback_speed": "Wiedergabegeschwindigkeit",
|
||||
"toggle_fullscreen": "Vollbild umschalten (F)",
|
||||
"video_speed": "Video-Geschwindigkeit. Erhöhen (+ oder >), Verringern (- oder <)",
|
||||
"toggle_mute": "Stummschalten umschalten (M), Lauter (OBEN), Leiser (UNTEN)",
|
||||
"seek_video": "Video in 10%-Schritten vorspulen (0–9)",
|
||||
"reload_video": "Video neu laden (R)",
|
||||
"play_pause": "Wiedergabe/Pause (Leertaste)"
|
||||
},
|
||||
"index": {
|
||||
"title": "Index-Manager",
|
||||
"create_new_index": "Neuen Index erstellen",
|
||||
"add_metadata": "Metadaten hinzufügen",
|
||||
"publishing_metadata": "Metadaten werden veröffentlicht...",
|
||||
"published_metadata": "Metadaten erfolgreich veröffentlicht",
|
||||
"failed_metadata": "Veröffentlichung der Metadaten fehlgeschlagen",
|
||||
"example": "Beispiel, wie es aussehen könnte:",
|
||||
"metadata_title": "Titel",
|
||||
"metadata_description": "Beschreibung",
|
||||
"metadata_title_placeholder": "Titel für den Link hinzufügen",
|
||||
"metadata_description_placeholder": "Beschreibung für den Link hinzufügen",
|
||||
"characters": "Zeichen",
|
||||
"publishing_index": "Index wird veröffentlicht...",
|
||||
"published_index": "Index erfolgreich veröffentlicht",
|
||||
"failed_index": "Veröffentlichung des Index fehlgeschlagen",
|
||||
"recommended_indices": "Empfohlene Indizes",
|
||||
"add_search_term": "Suchbegriff hinzufügen",
|
||||
"search_terms": "Suchbegriffe",
|
||||
"recommendation_size": "Es wird empfohlen, die Zeichenanzahl pro Begriff unter {{recommendedSize}} Zeichen zu halten",
|
||||
"multiple_title": "Mehrere Indizes hinzufügen",
|
||||
"multiple_description": "Weitere Indizes senken die Veröffentlichungsgebühren, haben aber weniger Gewicht in zukünftigen Suchergebnissen."
|
||||
},
|
||||
"multi_publish": {
|
||||
"title": "Veröffentlichungsstatus",
|
||||
"publish_failed": "Veröffentlichung fehlgeschlagen",
|
||||
"file_chunk": "Dateifragment",
|
||||
"file_processing": "Datei wird verarbeitet",
|
||||
"success": "Erfolgreich veröffentlicht",
|
||||
"attempt_retry": "Veröffentlichung fehlgeschlagen. Erneuter Versuch..."
|
||||
}
|
||||
}
|
||||
70
src/i18n/locales/en/lib-core.json
Normal file
70
src/i18n/locales/en/lib-core.json
Normal file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"subtitle": {
|
||||
"subtitles": "Subtitles",
|
||||
"no_subtitles": "No subtitles",
|
||||
"load_community_subs": "Load community subtitles",
|
||||
"off": "Off",
|
||||
"deleting_subtitle": "Deleting subtitle...",
|
||||
"deleted": "Deleted subtitle",
|
||||
"unable_delete": "unable to delete",
|
||||
"publishing": "Publishing subtitles...",
|
||||
"published": "Subtitles published",
|
||||
"unable_publish": "Unable to publish",
|
||||
"my_subtitles": "My Subtitles",
|
||||
"new": "New",
|
||||
"existing": "Existing",
|
||||
"import_subtitles": "Import subtitles"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "remove",
|
||||
"publish": "publish",
|
||||
"delete": "delete",
|
||||
"publish_metadata": "publish metadata",
|
||||
"publish_index": "publish index",
|
||||
"cancel": "cancel",
|
||||
"continue": "continue",
|
||||
"close": "close",
|
||||
"retry": "retry"
|
||||
},
|
||||
"video": {
|
||||
"playback_speed": "Playback speed",
|
||||
"toggle_fullscreen": "Toggle Fullscreen (F)",
|
||||
"video_speed": "Video Speed. Increase (+ or >), Decrease (- or <)",
|
||||
"toggle_mute": "Toggle Mute (M), Raise (UP), Lower (DOWN)",
|
||||
"seek_video": "Seek video in 10% increments (0-9)",
|
||||
"reload_video": "Reload Video (R)",
|
||||
"play_pause": "Pause/Play (Spacebar)"
|
||||
},
|
||||
"index": {
|
||||
"title": "Index Manager",
|
||||
"create_new_index": "Create new index",
|
||||
"add_metadata": "Add metadata",
|
||||
"publishing_metadata": "Publishing metadata...",
|
||||
"published_metadata": "Successfully published metadata",
|
||||
"failed_metadata": "Failed to publish metadata",
|
||||
"example": "Example of how it could look like:",
|
||||
"metadata_title": "Title",
|
||||
"metadata_description": "Description",
|
||||
"metadata_title_placeholder": "Add a title for the link",
|
||||
"metadata_description_placeholder": "Add a description for the link",
|
||||
"characters": "characters",
|
||||
"publishing_index": "Publishing index...",
|
||||
"published_index": "Successfully published index",
|
||||
"failed_index": "Failed to publish index",
|
||||
"recommended_indices": "Recommended Indices",
|
||||
"add_search_term": "Add search term",
|
||||
"search_terms": "search terms",
|
||||
"recommendation_size": "It is recommended to keep your term character count below {{recommendedSize}} characters",
|
||||
"multiple_title": "Adding multiple indices",
|
||||
"multiple_description": "Subsequent indices will keep your publish fees lower, but they will have less strength in future search results."
|
||||
|
||||
},
|
||||
"multi_publish": {
|
||||
"title": "Publishing Status",
|
||||
"publish_failed": "Publish Failed",
|
||||
"file_chunk": "File Chunk",
|
||||
"file_processing": "File Processing",
|
||||
"success": "Published successfully",
|
||||
"attempt_retry": "Publish failed. Attempting retry..."
|
||||
}
|
||||
}
|
||||
69
src/i18n/locales/es/lib-core.json
Normal file
69
src/i18n/locales/es/lib-core.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"subtitle": {
|
||||
"subtitles": "Subtítulos",
|
||||
"no_subtitles": "Sin subtítulos",
|
||||
"load_community_subs": "Cargar subtítulos de la comunidad",
|
||||
"off": "Desactivado",
|
||||
"deleting_subtitle": "Eliminando subtítulo...",
|
||||
"deleted": "Subtítulo eliminado",
|
||||
"unable_delete": "No se pudo eliminar",
|
||||
"publishing": "Publicando subtítulos...",
|
||||
"published": "Subtítulos publicados",
|
||||
"unable_publish": "No se pudo publicar",
|
||||
"my_subtitles": "Mis subtítulos",
|
||||
"new": "Nuevo",
|
||||
"existing": "Existente",
|
||||
"import_subtitles": "Importar subtítulos"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "eliminar",
|
||||
"publish": "publicar",
|
||||
"delete": "borrar",
|
||||
"publish_metadata": "publicar metadatos",
|
||||
"publish_index": "publicar índice",
|
||||
"cancel": "cancelar",
|
||||
"continue": "continuar",
|
||||
"close": "cerrar",
|
||||
"retry": "reintentar"
|
||||
},
|
||||
"video": {
|
||||
"playback_speed": "Velocidad de reproducción",
|
||||
"toggle_fullscreen": "Alternar pantalla completa (F)",
|
||||
"video_speed": "Velocidad del video. Aumentar (+ o >), Disminuir (- o <)",
|
||||
"toggle_mute": "Alternar silencio (M), Subir volumen (ARRIBA), Bajar volumen (ABAJO)",
|
||||
"seek_video": "Buscar en el video en incrementos del 10% (0-9)",
|
||||
"reload_video": "Recargar video (R)",
|
||||
"play_pause": "Pausar/Reproducir (Barra espaciadora)"
|
||||
},
|
||||
"index": {
|
||||
"title": "Gestor de Índices",
|
||||
"create_new_index": "Crear nuevo índice",
|
||||
"add_metadata": "Agregar metadatos",
|
||||
"publishing_metadata": "Publicando metadatos...",
|
||||
"published_metadata": "Metadatos publicados correctamente",
|
||||
"failed_metadata": "Error al publicar metadatos",
|
||||
"example": "Ejemplo de cómo podría verse:",
|
||||
"metadata_title": "Título",
|
||||
"metadata_description": "Descripción",
|
||||
"metadata_title_placeholder": "Agregar un título para el enlace",
|
||||
"metadata_description_placeholder": "Agregar una descripción para el enlace",
|
||||
"characters": "caracteres",
|
||||
"publishing_index": "Publicando índice...",
|
||||
"published_index": "Índice publicado correctamente",
|
||||
"failed_index": "Error al publicar el índice",
|
||||
"recommended_indices": "Índices recomendados",
|
||||
"add_search_term": "Agregar término de búsqueda",
|
||||
"search_terms": "términos de búsqueda",
|
||||
"recommendation_size": "Se recomienda mantener el número de caracteres por término por debajo de {{recommendedSize}} caracteres",
|
||||
"multiple_title": "Agregando múltiples índices",
|
||||
"multiple_description": "Los índices subsiguientes mantendrán más bajos los costos de publicación, pero tendrán menos peso en los resultados de búsqueda futuros."
|
||||
},
|
||||
"multi_publish": {
|
||||
"title": "Estado de Publicación",
|
||||
"publish_failed": "Fallo en la publicación",
|
||||
"file_chunk": "Fragmento de archivo",
|
||||
"file_processing": "Procesando archivo",
|
||||
"success": "Publicado con éxito",
|
||||
"attempt_retry": "Error en la publicación. Intentando reintentar..."
|
||||
}
|
||||
}
|
||||
69
src/i18n/locales/fr/lib-core.json
Normal file
69
src/i18n/locales/fr/lib-core.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"subtitle": {
|
||||
"subtitles": "Sous-titres",
|
||||
"no_subtitles": "Aucun sous-titre",
|
||||
"load_community_subs": "Charger les sous-titres de la communauté",
|
||||
"off": "Désactivé",
|
||||
"deleting_subtitle": "Suppression du sous-titre...",
|
||||
"deleted": "Sous-titre supprimé",
|
||||
"unable_delete": "Impossible de supprimer",
|
||||
"publishing": "Publication des sous-titres...",
|
||||
"published": "Sous-titres publiés",
|
||||
"unable_publish": "Impossible de publier",
|
||||
"my_subtitles": "Mes sous-titres",
|
||||
"new": "Nouveau",
|
||||
"existing": "Existant",
|
||||
"import_subtitles": "Importer des sous-titres"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "supprimer",
|
||||
"publish": "publier",
|
||||
"delete": "effacer",
|
||||
"publish_metadata": "publier les métadonnées",
|
||||
"publish_index": "publier l’index",
|
||||
"cancel": "annuler",
|
||||
"continue": "continuer",
|
||||
"close": "fermer",
|
||||
"retry": "réessayer"
|
||||
},
|
||||
"video": {
|
||||
"playback_speed": "Vitesse de lecture",
|
||||
"toggle_fullscreen": "Plein écran (F)",
|
||||
"video_speed": "Vitesse de la vidéo. Augmenter (+ ou >), Diminuer (- ou <)",
|
||||
"toggle_mute": "Activer/désactiver le son (M), Monter (HAUT), Baisser (BAS)",
|
||||
"seek_video": "Avancer dans la vidéo par incréments de 10 % (0–9)",
|
||||
"reload_video": "Recharger la vidéo (R)",
|
||||
"play_pause": "Lecture/Pause (Barre d’espace)"
|
||||
},
|
||||
"index": {
|
||||
"title": "Gestionnaire d’index",
|
||||
"create_new_index": "Créer un nouvel index",
|
||||
"add_metadata": "Ajouter des métadonnées",
|
||||
"publishing_metadata": "Publication des métadonnées...",
|
||||
"published_metadata": "Métadonnées publiées avec succès",
|
||||
"failed_metadata": "Échec de la publication des métadonnées",
|
||||
"example": "Exemple de présentation :",
|
||||
"metadata_title": "Titre",
|
||||
"metadata_description": "Description",
|
||||
"metadata_title_placeholder": "Ajouter un titre pour le lien",
|
||||
"metadata_description_placeholder": "Ajouter une description pour le lien",
|
||||
"characters": "caractères",
|
||||
"publishing_index": "Publication de l’index...",
|
||||
"published_index": "Index publié avec succès",
|
||||
"failed_index": "Échec de la publication de l’index",
|
||||
"recommended_indices": "Index recommandés",
|
||||
"add_search_term": "Ajouter un terme de recherche",
|
||||
"search_terms": "termes de recherche",
|
||||
"recommendation_size": "Il est recommandé de limiter chaque terme à moins de {{recommendedSize}} caractères",
|
||||
"multiple_title": "Ajout d’index multiples",
|
||||
"multiple_description": "Les index supplémentaires réduisent les frais de publication, mais auront moins d’impact dans les résultats de recherche futurs."
|
||||
},
|
||||
"multi_publish": {
|
||||
"title": "Statut de publication",
|
||||
"publish_failed": "Échec de la publication",
|
||||
"file_chunk": "Morceau de fichier",
|
||||
"file_processing": "Traitement du fichier",
|
||||
"success": "Publié avec succès",
|
||||
"attempt_retry": "Échec de la publication. Nouvelle tentative..."
|
||||
}
|
||||
}
|
||||
69
src/i18n/locales/it/lib-core.json
Normal file
69
src/i18n/locales/it/lib-core.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"subtitle": {
|
||||
"subtitles": "Sottotitoli",
|
||||
"no_subtitles": "Nessun sottotitolo",
|
||||
"load_community_subs": "Carica sottotitoli della comunità",
|
||||
"off": "Disattivato",
|
||||
"deleting_subtitle": "Eliminazione sottotitolo in corso...",
|
||||
"deleted": "Sottotitolo eliminato",
|
||||
"unable_delete": "Impossibile eliminare",
|
||||
"publishing": "Pubblicazione dei sottotitoli in corso...",
|
||||
"published": "Sottotitoli pubblicati",
|
||||
"unable_publish": "Impossibile pubblicare",
|
||||
"my_subtitles": "I miei sottotitoli",
|
||||
"new": "Nuovo",
|
||||
"existing": "Esistente",
|
||||
"import_subtitles": "Importa sottotitoli"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "rimuovi",
|
||||
"publish": "pubblica",
|
||||
"delete": "elimina",
|
||||
"publish_metadata": "pubblica metadati",
|
||||
"publish_index": "pubblica indice",
|
||||
"cancel": "annulla",
|
||||
"continue": "continua",
|
||||
"close": "chiudi",
|
||||
"retry": "ripeti"
|
||||
},
|
||||
"video": {
|
||||
"playback_speed": "Velocità di riproduzione",
|
||||
"toggle_fullscreen": "Attiva/disattiva schermo intero (F)",
|
||||
"video_speed": "Velocità video. Aumenta (+ o >), Diminuisci (- o <)",
|
||||
"toggle_mute": "Attiva/disattiva audio (M), Alza volume (SU), Abbassa volume (GIÙ)",
|
||||
"seek_video": "Vai avanti nel video a intervalli del 10% (0–9)",
|
||||
"reload_video": "Ricarica video (R)",
|
||||
"play_pause": "Riproduci/Pausa (Barra spaziatrice)"
|
||||
},
|
||||
"index": {
|
||||
"title": "Gestore degli Indici",
|
||||
"create_new_index": "Crea nuovo indice",
|
||||
"add_metadata": "Aggiungi metadati",
|
||||
"publishing_metadata": "Pubblicazione metadati in corso...",
|
||||
"published_metadata": "Metadati pubblicati con successo",
|
||||
"failed_metadata": "Errore nella pubblicazione dei metadati",
|
||||
"example": "Esempio di come potrebbe apparire:",
|
||||
"metadata_title": "Titolo",
|
||||
"metadata_description": "Descrizione",
|
||||
"metadata_title_placeholder": "Aggiungi un titolo per il link",
|
||||
"metadata_description_placeholder": "Aggiungi una descrizione per il link",
|
||||
"characters": "caratteri",
|
||||
"publishing_index": "Pubblicazione indice in corso...",
|
||||
"published_index": "Indice pubblicato con successo",
|
||||
"failed_index": "Errore nella pubblicazione dell'indice",
|
||||
"recommended_indices": "Indici consigliati",
|
||||
"add_search_term": "Aggiungi termine di ricerca",
|
||||
"search_terms": "termini di ricerca",
|
||||
"recommendation_size": "Si consiglia di mantenere il numero di caratteri per termine inferiore a {{recommendedSize}} caratteri",
|
||||
"multiple_title": "Aggiunta di indici multipli",
|
||||
"multiple_description": "Indici successivi ridurranno le commissioni di pubblicazione, ma avranno meno rilevanza nei risultati di ricerca futuri."
|
||||
},
|
||||
"multi_publish": {
|
||||
"title": "Stato della pubblicazione",
|
||||
"publish_failed": "Pubblicazione fallita",
|
||||
"file_chunk": "Frammento di file",
|
||||
"file_processing": "Elaborazione file in corso",
|
||||
"success": "Pubblicato con successo",
|
||||
"attempt_retry": "Pubblicazione fallita. Tentativo di nuovo..."
|
||||
}
|
||||
}
|
||||
69
src/i18n/locales/ja/lib-core.json
Normal file
69
src/i18n/locales/ja/lib-core.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"subtitle": {
|
||||
"subtitles": "字幕",
|
||||
"no_subtitles": "字幕なし",
|
||||
"load_community_subs": "コミュニティ字幕を読み込む",
|
||||
"off": "オフ",
|
||||
"deleting_subtitle": "字幕を削除中...",
|
||||
"deleted": "字幕を削除しました",
|
||||
"unable_delete": "削除できませんでした",
|
||||
"publishing": "字幕を公開中...",
|
||||
"published": "字幕を公開しました",
|
||||
"unable_publish": "公開できませんでした",
|
||||
"my_subtitles": "自分の字幕",
|
||||
"new": "新規",
|
||||
"existing": "既存",
|
||||
"import_subtitles": "字幕をインポート"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "削除する",
|
||||
"publish": "公開する",
|
||||
"delete": "削除",
|
||||
"publish_metadata": "メタデータを公開",
|
||||
"publish_index": "インデックスを公開",
|
||||
"cancel": "キャンセル",
|
||||
"continue": "続行",
|
||||
"close": "閉じる",
|
||||
"retry": "再試行"
|
||||
},
|
||||
"video": {
|
||||
"playback_speed": "再生速度",
|
||||
"toggle_fullscreen": "フルスクリーン切替 (F)",
|
||||
"video_speed": "動画速度:速くする(+ または >)、遅くする(- または <)",
|
||||
"toggle_mute": "ミュート切替 (M)、音量を上げる(↑)、下げる(↓)",
|
||||
"seek_video": "動画を10%ずつシーク(0〜9)",
|
||||
"reload_video": "動画を再読み込み (R)",
|
||||
"play_pause": "再生/一時停止(スペースキー)"
|
||||
},
|
||||
"index": {
|
||||
"title": "インデックスマネージャー",
|
||||
"create_new_index": "新しいインデックスを作成",
|
||||
"add_metadata": "メタデータを追加",
|
||||
"publishing_metadata": "メタデータを公開中...",
|
||||
"published_metadata": "メタデータの公開に成功しました",
|
||||
"failed_metadata": "メタデータの公開に失敗しました",
|
||||
"example": "表示例:",
|
||||
"metadata_title": "タイトル",
|
||||
"metadata_description": "説明",
|
||||
"metadata_title_placeholder": "リンクのタイトルを入力",
|
||||
"metadata_description_placeholder": "リンクの説明を入力",
|
||||
"characters": "文字",
|
||||
"publishing_index": "インデックスを公開中...",
|
||||
"published_index": "インデックスを公開しました",
|
||||
"failed_index": "インデックスの公開に失敗しました",
|
||||
"recommended_indices": "おすすめのインデックス",
|
||||
"add_search_term": "検索語を追加",
|
||||
"search_terms": "検索語",
|
||||
"recommendation_size": "検索語は{{recommendedSize}}文字未満に抑えることを推奨します",
|
||||
"multiple_title": "複数のインデックスを追加",
|
||||
"multiple_description": "複数のインデックスにより公開コストを抑えられますが、検索結果での影響は小さくなります。"
|
||||
},
|
||||
"multi_publish": {
|
||||
"title": "公開状況",
|
||||
"publish_failed": "公開に失敗しました",
|
||||
"file_chunk": "ファイルチャンク",
|
||||
"file_processing": "ファイル処理中",
|
||||
"success": "公開に成功しました",
|
||||
"attempt_retry": "公開に失敗しました。再試行中..."
|
||||
}
|
||||
}
|
||||
69
src/i18n/locales/ru/lib-core.json
Normal file
69
src/i18n/locales/ru/lib-core.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"subtitle": {
|
||||
"subtitles": "Субтитры",
|
||||
"no_subtitles": "Нет субтитров",
|
||||
"load_community_subs": "Загрузить субтитры сообщества",
|
||||
"off": "Выключено",
|
||||
"deleting_subtitle": "Удаление субтитров...",
|
||||
"deleted": "Субтитры удалены",
|
||||
"unable_delete": "Не удалось удалить",
|
||||
"publishing": "Публикация субтитров...",
|
||||
"published": "Субтитры опубликованы",
|
||||
"unable_publish": "Не удалось опубликовать",
|
||||
"my_subtitles": "Мои субтитры",
|
||||
"new": "Новые",
|
||||
"existing": "Существующие",
|
||||
"import_subtitles": "Импорт субтитров"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "удалить",
|
||||
"publish": "опубликовать",
|
||||
"delete": "удалить",
|
||||
"publish_metadata": "опубликовать метаданные",
|
||||
"publish_index": "опубликовать индекс",
|
||||
"cancel": "отмена",
|
||||
"continue": "продолжить",
|
||||
"close": "закрыть",
|
||||
"retry": "повторить"
|
||||
},
|
||||
"video": {
|
||||
"playback_speed": "Скорость воспроизведения",
|
||||
"toggle_fullscreen": "Полный экран (F)",
|
||||
"video_speed": "Скорость видео. Увеличить (+ или >), Уменьшить (- или <)",
|
||||
"toggle_mute": "Вкл/выкл звук (M), Громче (ВВЕРХ), Тише (ВНИЗ)",
|
||||
"seek_video": "Переход по видео на 10% (0–9)",
|
||||
"reload_video": "Перезагрузить видео (R)",
|
||||
"play_pause": "Воспроизвести/Пауза (Пробел)"
|
||||
},
|
||||
"index": {
|
||||
"title": "Менеджер индексов",
|
||||
"create_new_index": "Создать новый индекс",
|
||||
"add_metadata": "Добавить метаданные",
|
||||
"publishing_metadata": "Публикация метаданных...",
|
||||
"published_metadata": "Метаданные успешно опубликованы",
|
||||
"failed_metadata": "Не удалось опубликовать метаданные",
|
||||
"example": "Пример того, как это может выглядеть:",
|
||||
"metadata_title": "Заголовок",
|
||||
"metadata_description": "Описание",
|
||||
"metadata_title_placeholder": "Добавьте заголовок для ссылки",
|
||||
"metadata_description_placeholder": "Добавьте описание для ссылки",
|
||||
"characters": "символов",
|
||||
"publishing_index": "Публикация индекса...",
|
||||
"published_index": "Индекс успешно опубликован",
|
||||
"failed_index": "Не удалось опубликовать индекс",
|
||||
"recommended_indices": "Рекомендуемые индексы",
|
||||
"add_search_term": "Добавить поисковый запрос",
|
||||
"search_terms": "поисковые запросы",
|
||||
"recommendation_size": "Рекомендуется использовать не более {{recommendedSize}} символов на запрос",
|
||||
"multiple_title": "Добавление нескольких индексов",
|
||||
"multiple_description": "Дополнительные индексы снижают стоимость публикации, но будут иметь меньший вес в результатах поиска в будущем."
|
||||
},
|
||||
"multi_publish": {
|
||||
"title": "Статус публикации",
|
||||
"publish_failed": "Ошибка публикации",
|
||||
"file_chunk": "Фрагмент файла",
|
||||
"file_processing": "Обработка файла",
|
||||
"success": "Успешно опубликовано",
|
||||
"attempt_retry": "Публикация не удалась. Повторная попытка..."
|
||||
}
|
||||
}
|
||||
69
src/i18n/locales/zh/lib-core.json
Normal file
69
src/i18n/locales/zh/lib-core.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"subtitle": {
|
||||
"subtitles": "字幕",
|
||||
"no_subtitles": "无字幕",
|
||||
"load_community_subs": "加载社区字幕",
|
||||
"off": "关闭",
|
||||
"deleting_subtitle": "正在删除字幕...",
|
||||
"deleted": "字幕已删除",
|
||||
"unable_delete": "无法删除",
|
||||
"publishing": "正在发布字幕...",
|
||||
"published": "字幕已发布",
|
||||
"unable_publish": "无法发布",
|
||||
"my_subtitles": "我的字幕",
|
||||
"new": "新建",
|
||||
"existing": "已有",
|
||||
"import_subtitles": "导入字幕"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "移除",
|
||||
"publish": "发布",
|
||||
"delete": "删除",
|
||||
"publish_metadata": "发布元数据",
|
||||
"publish_index": "发布索引",
|
||||
"cancel": "取消",
|
||||
"continue": "继续",
|
||||
"close": "关闭",
|
||||
"retry": "重试"
|
||||
},
|
||||
"video": {
|
||||
"playback_speed": "播放速度",
|
||||
"toggle_fullscreen": "切换全屏 (F)",
|
||||
"video_speed": "视频速度。加快 (+ 或 >),减慢 (- 或 <)",
|
||||
"toggle_mute": "切换静音 (M),音量加 (↑),音量减 (↓)",
|
||||
"seek_video": "按 10% 增量跳转视频 (0–9)",
|
||||
"reload_video": "重新加载视频 (R)",
|
||||
"play_pause": "播放/暂停 (空格键)"
|
||||
},
|
||||
"index": {
|
||||
"title": "索引管理器",
|
||||
"create_new_index": "创建新索引",
|
||||
"add_metadata": "添加元数据",
|
||||
"publishing_metadata": "正在发布元数据...",
|
||||
"published_metadata": "元数据发布成功",
|
||||
"failed_metadata": "元数据发布失败",
|
||||
"example": "示例外观如下:",
|
||||
"metadata_title": "标题",
|
||||
"metadata_description": "描述",
|
||||
"metadata_title_placeholder": "为链接添加标题",
|
||||
"metadata_description_placeholder": "为链接添加描述",
|
||||
"characters": "字符",
|
||||
"publishing_index": "正在发布索引...",
|
||||
"published_index": "索引发布成功",
|
||||
"failed_index": "索引发布失败",
|
||||
"recommended_indices": "推荐索引",
|
||||
"add_search_term": "添加搜索词",
|
||||
"search_terms": "搜索词",
|
||||
"recommendation_size": "建议每个搜索词不超过 {{recommendedSize}} 个字符",
|
||||
"multiple_title": "添加多个索引",
|
||||
"multiple_description": "多个索引可以降低发布费用,但在未来搜索结果中的权重会较低。"
|
||||
},
|
||||
"multi_publish": {
|
||||
"title": "发布状态",
|
||||
"publish_failed": "发布失败",
|
||||
"file_chunk": "文件片段",
|
||||
"file_processing": "正在处理文件",
|
||||
"success": "发布成功",
|
||||
"attempt_retry": "发布失败。正在尝试重试..."
|
||||
}
|
||||
}
|
||||
53
src/i18n/processors.ts
Normal file
53
src/i18n/processors.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export const capitalizeAll = {
|
||||
type: 'postProcessor',
|
||||
name: 'capitalizeAll',
|
||||
process: (value: string) => value.toUpperCase(),
|
||||
};
|
||||
|
||||
export const capitalizeEachFirstChar = {
|
||||
type: 'postProcessor',
|
||||
name: 'capitalizeEachFirstChar',
|
||||
process: (value: string) => {
|
||||
if (!value?.trim()) return value;
|
||||
|
||||
const leadingSpaces = value.match(/^\s*/)?.[0] || '';
|
||||
const trailingSpaces = value.match(/\s*$/)?.[0] || '';
|
||||
|
||||
const core = value
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map(
|
||||
(word) =>
|
||||
word.charAt(0).toLocaleUpperCase() + word.slice(1).toLocaleLowerCase()
|
||||
)
|
||||
.join(' ');
|
||||
|
||||
return leadingSpaces + core + trailingSpaces;
|
||||
},
|
||||
};
|
||||
export const capitalizeFirstChar = {
|
||||
type: 'postProcessor',
|
||||
name: 'capitalizeFirstChar',
|
||||
process: (value: string) => value.charAt(0).toUpperCase() + value.slice(1),
|
||||
};
|
||||
|
||||
export const capitalizeFirstWord = {
|
||||
type: 'postProcessor',
|
||||
name: 'capitalizeFirstWord',
|
||||
process: (value: string) => {
|
||||
if (!value?.trim()) return value;
|
||||
|
||||
const trimmed = value.trimStart();
|
||||
const firstSpaceIndex = trimmed.indexOf(' ');
|
||||
|
||||
if (firstSpaceIndex === -1) {
|
||||
return trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
|
||||
}
|
||||
|
||||
const firstWord = trimmed.slice(0, firstSpaceIndex);
|
||||
const restOfString = trimmed.slice(firstSpaceIndex);
|
||||
const trailingSpaces = value.slice(trimmed.length);
|
||||
|
||||
return firstWord.toUpperCase() + restOfString + trailingSpaces;
|
||||
},
|
||||
};
|
||||
@@ -4,4 +4,16 @@
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
video:focus,
|
||||
video:focus-visible,
|
||||
.vjs-tech:focus,
|
||||
.vjs-tech:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.video-js *:focus:not(:focus-visible) {
|
||||
outline: none !important;
|
||||
}
|
||||
11
src/index.ts
11
src/index.ts
@@ -4,7 +4,14 @@ export { useResourceStatus } from './hooks/useResourceStatus';
|
||||
export { Spacer } from './common/Spacer';
|
||||
export { useModal } from './hooks/useModal';
|
||||
export { AudioPlayerControls , OnTrackChangeMeta, AudioPlayerProps, AudioPlayerHandle} from './components/AudioPlayer/AudioPlayerControls';
|
||||
export {TimelineAction} from './components/VideoPlayer/VideoPlayer'
|
||||
export { useAudioPlayerHotkeys } from './components/AudioPlayer/useAudioPlayerHotkeys';
|
||||
export { VideoPlayerParent as VideoPlayer } from './components/VideoPlayer/VideoPlayerParent';
|
||||
export { useListReturn } from './hooks/useListData';
|
||||
export { useAllResourceStatus } from './hooks/useAllResourceStatus';
|
||||
export { useQortBalance } from './hooks/useBalance';
|
||||
export { useAuth } from './hooks/useAuth';
|
||||
export { useBlockedNames } from './hooks/useBlockedNames';
|
||||
import './index.css'
|
||||
export { executeEvent, subscribeToEvent, unsubscribeFromEvent } from './utils/events';
|
||||
export { formatBytes, formatDuration } from './utils/numbers';
|
||||
@@ -13,7 +20,7 @@ export { IndexCategory } from './state/indexes';
|
||||
export { hashWordWithoutPublicSalt } from './utils/encryption';
|
||||
export { createAvatarLink } from './utils/qortal';
|
||||
export { objectToBase64 } from './utils/base64';
|
||||
export { generateBloomFilterBase64, isInsideBloom } from './utils/bloomFilter';
|
||||
|
||||
export { addAndEncryptSymmetricKeys, decryptWithSymmetricKeys, encryptWithSymmetricKeys } from './utils/encryption';
|
||||
export { base64ToObject } from './utils/publish';
|
||||
export { formatTimestamp } from './utils/time';
|
||||
@@ -24,6 +31,7 @@ export { RequestQueueWithPromise } from './utils/queue';
|
||||
export { GlobalProvider, useGlobal } from "./context/GlobalProvider";
|
||||
export {usePublish} from "./hooks/usePublish"
|
||||
export {ResourceListDisplay} from "./components/ResourceList/ResourceListDisplay"
|
||||
export {ResourceListPreloadedDisplay} from "./components/ResourceList/ResourceListPreloadedDisplay"
|
||||
export {QortalSearchParams, QortalMetadata} from './types/interfaces/resources'
|
||||
export {ImagePicker} from './common/ImagePicker'
|
||||
export {useNameSearch} from './hooks/useNameSearch'
|
||||
@@ -32,4 +40,3 @@ export {Service, QortalGetMetadata} from './types/interfaces/resources'
|
||||
export {ListItem} from './state/cache'
|
||||
export {SymmetricKeys} from './utils/encryption'
|
||||
export {LoaderListStatus} from './common/ListLoader'
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ interface AuthState {
|
||||
setIsLoadingUser: (loading: boolean) => void;
|
||||
setIsLoadingBalance: (loading: boolean) => void;
|
||||
setErrorLoadingUser: (error: string | null) => void;
|
||||
setName: (name: string | null) => void;
|
||||
}
|
||||
|
||||
// ✅ Typed Zustand Store
|
||||
@@ -43,4 +44,11 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
setIsLoadingUser: (loading) => set({ isLoadingUser: loading }),
|
||||
setIsLoadingBalance: (loading) => set({ isLoadingInitialBalance: loading }),
|
||||
setErrorLoadingUser: (error) => set({ errorLoadingUser: error }),
|
||||
setName: (name) =>
|
||||
set({
|
||||
name,
|
||||
avatarUrl: !name
|
||||
? null
|
||||
: `/arbitrary/THUMBNAIL/${encodeURIComponent(name)}/qortal_avatar?async=true`,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
import { create } from "zustand";
|
||||
import { QortalGetMetadata, QortalMetadata } from "../types/interfaces/resources";
|
||||
import {
|
||||
QortalGetMetadata,
|
||||
QortalMetadata,
|
||||
} from "../types/interfaces/resources";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
|
||||
interface SearchCache {
|
||||
[listName: string]: {
|
||||
searches: {
|
||||
[searchTerm: string]: QortalMetadata[]; // List of products for each search term
|
||||
};
|
||||
temporaryNewResources: QortalMetadata[],
|
||||
temporaryNewResources: QortalMetadata[];
|
||||
expiry: number; // Expiry timestamp for the whole list
|
||||
searchParamsStringified: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const mergeUniqueItems = (array1: QortalMetadata[], array2: QortalMetadata[]) => {
|
||||
export const mergeUniqueItems = (
|
||||
array1: QortalMetadata[],
|
||||
array2: QortalMetadata[]
|
||||
) => {
|
||||
const mergedArray = [...array1, ...array2];
|
||||
|
||||
// Use a Map to ensure uniqueness based on `identifier-name`
|
||||
const uniqueMap = new Map();
|
||||
|
||||
mergedArray.forEach(item => {
|
||||
mergedArray.forEach((item) => {
|
||||
if (item.identifier && item.name && item.service) {
|
||||
const key = `${item.service}-${item.name}-${item.identifier}`;
|
||||
uniqueMap.set(key, item);
|
||||
@@ -33,211 +36,278 @@ export const mergeUniqueItems = (array1: QortalMetadata[], array2: QortalMetadat
|
||||
};
|
||||
|
||||
export interface ListItem {
|
||||
data: any
|
||||
qortalMetadata: QortalMetadata
|
||||
data: any;
|
||||
qortalMetadata: QortalMetadata;
|
||||
}
|
||||
|
||||
|
||||
interface resourceCache {
|
||||
[id: string]: {
|
||||
data: ListItem | false | null; // Cached resource data
|
||||
expiry: number; // Expiry timestamp in milliseconds
|
||||
};
|
||||
}
|
||||
[id: string]: {
|
||||
data: ListItem | false | null; // Cached resource data
|
||||
expiry: number; // Expiry timestamp in milliseconds
|
||||
};
|
||||
}
|
||||
|
||||
interface DeletedResources {
|
||||
[key: string]: { deleted: true; expiry: number }; // ✅ Added expiry field
|
||||
}
|
||||
interface DeletedResources {
|
||||
[key: string]: { deleted: true; expiry: number }; // ✅ Added expiry field
|
||||
}
|
||||
|
||||
interface CacheState {
|
||||
resourceCache: resourceCache;
|
||||
|
||||
searchCache: SearchCache;
|
||||
// Search cache actions
|
||||
setResourceCache: (id: string, data: ListItem | false | null, customExpiry?: number) => void;
|
||||
setResourceCache: (
|
||||
id: string,
|
||||
data: ListItem | false | null,
|
||||
customExpiry?: number
|
||||
) => void;
|
||||
|
||||
setSearchCache: (listName: string, searchTerm: string, data: QortalMetadata[], searchParamsStringified: string | null, customExpiry?: number) => void;
|
||||
setSearchParamsForList: (ListName: string, searchParamsStringified: string)=> void;
|
||||
getSearchCache: (listName: string, searchTerm: string) => QortalMetadata[] | null;
|
||||
setSearchCache: (
|
||||
listName: string,
|
||||
searchTerm: string,
|
||||
data: QortalMetadata[],
|
||||
searchParamsStringified: string | null,
|
||||
customExpiry?: number
|
||||
) => void;
|
||||
setSearchParamsForList: (
|
||||
ListName: string,
|
||||
searchParamsStringified: string
|
||||
) => void;
|
||||
getSearchCache: (
|
||||
listName: string,
|
||||
searchTerm: string
|
||||
) => QortalMetadata[] | null;
|
||||
clearExpiredCache: () => void;
|
||||
getResourceCache: (id: string, ignoreExpire?: boolean) => ListItem | false | null;
|
||||
addTemporaryResource: (listName: string, newResources: QortalMetadata[], customExpiry?: number)=> void;
|
||||
getTemporaryResources:(listName: string)=> QortalMetadata[]
|
||||
getResourceCache: (
|
||||
id: string,
|
||||
ignoreExpire?: boolean
|
||||
) => ListItem | false | null;
|
||||
addTemporaryResource: (
|
||||
listName: string,
|
||||
newResources: QortalMetadata[],
|
||||
customExpiry?: number
|
||||
) => void;
|
||||
getTemporaryResources: (listName: string) => QortalMetadata[];
|
||||
deletedResources: DeletedResources;
|
||||
markResourceAsDeleted: (item: QortalMetadata | QortalGetMetadata) => void;
|
||||
filterOutDeletedResources: (items: QortalMetadata[]) => QortalMetadata[];
|
||||
isListExpired: (listName: string)=> boolean | string;
|
||||
isListExpired: (listName: string) => boolean | string;
|
||||
searchCacheExpiryDuration: number;
|
||||
resourceCacheExpiryDuration: number;
|
||||
setSearchCacheExpiryDuration: (duration: number) => void;
|
||||
setResourceCacheExpiryDuration: (duration: number)=> void;
|
||||
setResourceCacheExpiryDuration: (duration: number) => void;
|
||||
deleteSearchCache: (listName: string) => void;
|
||||
filterSearchCacheItemsByNames: (names: string[]) => void;
|
||||
}
|
||||
|
||||
export const useCacheStore = create<CacheState>
|
||||
((set, get) => ({
|
||||
searchCacheExpiryDuration: 5 * 60 * 1000,
|
||||
resourceCacheExpiryDuration: 30 * 60 * 1000,
|
||||
resourceCache: {},
|
||||
searchCache: {},
|
||||
deletedResources: {},
|
||||
setSearchCacheExpiryDuration: (duration) => set({ searchCacheExpiryDuration: duration }),
|
||||
setResourceCacheExpiryDuration: (duration) => set({ resourceCacheExpiryDuration: duration }),
|
||||
getResourceCache: (id, ignoreExpire) => {
|
||||
const cache = get().resourceCache[id];
|
||||
if (cache) {
|
||||
if (cache.expiry > Date.now() || ignoreExpire) {
|
||||
return cache.data; // ✅ Return data if not expired
|
||||
} else {
|
||||
set((state) => {
|
||||
const updatedCache = { ...state.resourceCache };
|
||||
delete updatedCache[id]; // ✅ Remove expired entry
|
||||
return { resourceCache: updatedCache };
|
||||
});
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
setResourceCache: (id, data, customExpiry) =>
|
||||
export const useCacheStore = create<CacheState>((set, get) => ({
|
||||
searchCacheExpiryDuration: 5 * 60 * 1000,
|
||||
resourceCacheExpiryDuration: 30 * 60 * 1000,
|
||||
resourceCache: {},
|
||||
searchCache: {},
|
||||
deletedResources: {},
|
||||
setSearchCacheExpiryDuration: (duration) =>
|
||||
set({ searchCacheExpiryDuration: duration }),
|
||||
setResourceCacheExpiryDuration: (duration) =>
|
||||
set({ resourceCacheExpiryDuration: duration }),
|
||||
getResourceCache: (id, ignoreExpire) => {
|
||||
const cache = get().resourceCache[id];
|
||||
if (cache) {
|
||||
if (cache.expiry > Date.now() || ignoreExpire) {
|
||||
return cache.data; // ✅ Return data if not expired
|
||||
} else {
|
||||
set((state) => {
|
||||
const expiry = Date.now() + (customExpiry || get().resourceCacheExpiryDuration);
|
||||
return {
|
||||
resourceCache: {
|
||||
...state.resourceCache,
|
||||
[id]: { data, expiry },
|
||||
},
|
||||
};
|
||||
}),
|
||||
const updatedCache = { ...state.resourceCache };
|
||||
delete updatedCache[id]; // ✅ Remove expired entry
|
||||
return { resourceCache: updatedCache };
|
||||
});
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
setSearchCache: (listName, searchTerm, data, searchParamsStringified, customExpiry) =>
|
||||
set((state) => {
|
||||
const expiry = Date.now() + (customExpiry || get().searchCacheExpiryDuration);
|
||||
|
||||
return {
|
||||
searchCache: {
|
||||
...state.searchCache,
|
||||
[listName]: {
|
||||
searches: {
|
||||
...(state.searchCache[listName]?.searches || {}),
|
||||
[searchTerm]: data,
|
||||
},
|
||||
temporaryNewResources: state.searchCache[listName]?.temporaryNewResources || [],
|
||||
expiry,
|
||||
searchParamsStringified: searchParamsStringified === null ? state.searchCache[listName]?.searchParamsStringified : searchParamsStringified
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
setSearchParamsForList: (listName, searchParamsStringified) =>
|
||||
set((state) => {
|
||||
const existingList = state.searchCache[listName] || {};
|
||||
|
||||
return {
|
||||
searchCache: {
|
||||
...state.searchCache,
|
||||
[listName]: {
|
||||
...existingList,
|
||||
searchParamsStringified,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
getSearchCache: (listName, searchTerm) => {
|
||||
const cache = get().searchCache[listName];
|
||||
if (cache) {
|
||||
if (cache.expiry > Date.now()) {
|
||||
return cache.searches[searchTerm] || null; // ✅ Return if valid
|
||||
} else {
|
||||
set((state) => {
|
||||
const updatedCache = { ...state.searchCache };
|
||||
delete updatedCache[listName]; // ✅ Remove expired list
|
||||
return { searchCache: updatedCache };
|
||||
});
|
||||
}
|
||||
}
|
||||
return null;
|
||||
setResourceCache: (id, data, customExpiry) =>
|
||||
set((state) => {
|
||||
const expiry =
|
||||
Date.now() + (customExpiry || get().resourceCacheExpiryDuration);
|
||||
return {
|
||||
resourceCache: {
|
||||
...state.resourceCache,
|
||||
[id]: { data, expiry },
|
||||
},
|
||||
|
||||
addTemporaryResource: (listName, newResources, customExpiry) =>
|
||||
set((state) => {
|
||||
|
||||
const expiry = Date.now() + (customExpiry || 5 * 60 * 1000);
|
||||
|
||||
const existingResources = state.searchCache[listName]?.temporaryNewResources || [];
|
||||
|
||||
// Merge & remove duplicates, keeping the latest by `created` timestamp
|
||||
const uniqueResourcesMap = new Map<string, QortalMetadata>();
|
||||
|
||||
[...existingResources, ...newResources].forEach((item) => {
|
||||
const key = `${item.service}-${item.name}-${item.identifier}`;
|
||||
const existingItem = uniqueResourcesMap.get(key);
|
||||
|
||||
if (!existingItem || item.created > existingItem.created) {
|
||||
uniqueResourcesMap.set(key, item);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
searchCache: {
|
||||
...state.searchCache,
|
||||
[listName]: {
|
||||
...state.searchCache[listName],
|
||||
temporaryNewResources: Array.from(uniqueResourcesMap.values()),
|
||||
expiry,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
getTemporaryResources: (listName: string) => {
|
||||
const cache = get().searchCache[listName];
|
||||
if (cache && cache.expiry > Date.now()) {
|
||||
return cache.temporaryNewResources || [];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
markResourceAsDeleted: (item) =>
|
||||
set((state) => {
|
||||
const now = Date.now();
|
||||
const expiry = now + 5 * 60 * 1000; // ✅ Expires in 5 minutes
|
||||
|
||||
// ✅ Remove expired deletions before adding a new one
|
||||
const validDeletedResources = Object.fromEntries(
|
||||
Object.entries(state.deletedResources).filter(([_, value]) => value.expiry > now)
|
||||
);
|
||||
|
||||
const key = `${item.service}-${item.name}-${item.identifier}`;
|
||||
return {
|
||||
deletedResources: {
|
||||
...validDeletedResources, // ✅ Keep only non-expired ones
|
||||
[key]: { deleted: true, expiry },
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
filterOutDeletedResources: (items) => {
|
||||
const deletedResources = get().deletedResources; // ✅ Read without modifying store
|
||||
return items.filter(
|
||||
(item) => !deletedResources[`${item.service}-${item.name}-${item.identifier}`]
|
||||
);
|
||||
},
|
||||
isListExpired: (listName: string): boolean | string => {
|
||||
const cache = get().searchCache[listName];
|
||||
const isExpired = cache ? cache.expiry <= Date.now() : true; // ✅ Expired if expiry timestamp is in the past
|
||||
return isExpired === true ? true : cache.searchParamsStringified
|
||||
},
|
||||
|
||||
|
||||
clearExpiredCache: () =>
|
||||
set((state) => {
|
||||
const now = Date.now();
|
||||
const validSearchCache = Object.fromEntries(
|
||||
Object.entries(state.searchCache).filter(([, value]) => value.expiry > now)
|
||||
);
|
||||
return { searchCache: validSearchCache };
|
||||
}),
|
||||
};
|
||||
}),
|
||||
|
||||
);
|
||||
|
||||
setSearchCache: (
|
||||
listName,
|
||||
searchTerm,
|
||||
data,
|
||||
searchParamsStringified,
|
||||
customExpiry
|
||||
) =>
|
||||
set((state) => {
|
||||
const expiry =
|
||||
Date.now() + (customExpiry || get().searchCacheExpiryDuration);
|
||||
|
||||
return {
|
||||
searchCache: {
|
||||
...state.searchCache,
|
||||
[listName]: {
|
||||
searches: {
|
||||
...(state.searchCache[listName]?.searches || {}),
|
||||
[searchTerm]: data,
|
||||
},
|
||||
temporaryNewResources:
|
||||
state.searchCache[listName]?.temporaryNewResources || [],
|
||||
expiry,
|
||||
searchParamsStringified:
|
||||
searchParamsStringified === null
|
||||
? state.searchCache[listName]?.searchParamsStringified
|
||||
: searchParamsStringified,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
deleteSearchCache: (listName) =>
|
||||
set((state) => {
|
||||
const updatedSearchCache = { ...state.searchCache };
|
||||
delete updatedSearchCache[listName];
|
||||
return { searchCache: updatedSearchCache };
|
||||
}),
|
||||
setSearchParamsForList: (listName, searchParamsStringified) =>
|
||||
set((state) => {
|
||||
const existingList = state.searchCache[listName] || {};
|
||||
|
||||
return {
|
||||
searchCache: {
|
||||
...state.searchCache,
|
||||
[listName]: {
|
||||
...existingList,
|
||||
searchParamsStringified,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
getSearchCache: (listName, searchTerm) => {
|
||||
const cache = get().searchCache[listName];
|
||||
if (cache) {
|
||||
if (cache.expiry > Date.now()) {
|
||||
return cache.searches[searchTerm] || null; // ✅ Return if valid
|
||||
} else {
|
||||
set((state) => {
|
||||
const updatedCache = { ...state.searchCache };
|
||||
delete updatedCache[listName]; // ✅ Remove expired list
|
||||
return { searchCache: updatedCache };
|
||||
});
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
addTemporaryResource: (listName, newResources, customExpiry) =>
|
||||
set((state) => {
|
||||
const expiry = Date.now() + (customExpiry || 5 * 60 * 1000);
|
||||
|
||||
const existingResources =
|
||||
state.searchCache[listName]?.temporaryNewResources || [];
|
||||
|
||||
// Merge & remove duplicates, keeping the latest by `created` timestamp
|
||||
const uniqueResourcesMap = new Map<string, QortalMetadata>();
|
||||
|
||||
[...existingResources, ...newResources].forEach((item) => {
|
||||
const key = `${item.service}-${item.name}-${item.identifier}`;
|
||||
const existingItem = uniqueResourcesMap.get(key);
|
||||
|
||||
if (!existingItem || item.created > existingItem.created) {
|
||||
uniqueResourcesMap.set(key, item);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
searchCache: {
|
||||
...state.searchCache,
|
||||
[listName]: {
|
||||
...state.searchCache[listName],
|
||||
temporaryNewResources: Array.from(uniqueResourcesMap.values()),
|
||||
expiry,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
getTemporaryResources: (listName: string) => {
|
||||
const cache = get().searchCache[listName];
|
||||
if (cache && cache.expiry > Date.now()) {
|
||||
const resources = cache.temporaryNewResources || [];
|
||||
return [...resources].sort((a, b) => b?.created - a?.created);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
markResourceAsDeleted: (item) =>
|
||||
set((state) => {
|
||||
const now = Date.now();
|
||||
const expiry = now + 5 * 60 * 1000; // ✅ Expires in 5 minutes
|
||||
|
||||
// ✅ Remove expired deletions before adding a new one
|
||||
const validDeletedResources = Object.fromEntries(
|
||||
Object.entries(state.deletedResources).filter(
|
||||
([_, value]) => value.expiry > now
|
||||
)
|
||||
);
|
||||
|
||||
const key = `${item.service}-${item.name}-${item.identifier}`;
|
||||
return {
|
||||
deletedResources: {
|
||||
...validDeletedResources, // ✅ Keep only non-expired ones
|
||||
[key]: { deleted: true, expiry },
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
filterOutDeletedResources: (items) => {
|
||||
const deletedResources = get().deletedResources; // ✅ Read without modifying store
|
||||
return items.filter(
|
||||
(item) =>
|
||||
!deletedResources[`${item.service}-${item.name}-${item.identifier}`]
|
||||
);
|
||||
},
|
||||
isListExpired: (listName: string): boolean | string => {
|
||||
const cache = get().searchCache[listName];
|
||||
const isExpired = cache ? cache.expiry <= Date.now() : true; // ✅ Expired if expiry timestamp is in the past
|
||||
return isExpired === true ? true : cache.searchParamsStringified;
|
||||
},
|
||||
|
||||
clearExpiredCache: () =>
|
||||
set((state) => {
|
||||
const now = Date.now();
|
||||
const validSearchCache = Object.fromEntries(
|
||||
Object.entries(state.searchCache).filter(
|
||||
([, value]) => value.expiry > now
|
||||
)
|
||||
);
|
||||
return { searchCache: validSearchCache };
|
||||
}),
|
||||
filterSearchCacheItemsByNames: (names) =>
|
||||
set((state) => {
|
||||
const updatedSearchCache: SearchCache = {};
|
||||
|
||||
for (const [listName, list] of Object.entries(state.searchCache)) {
|
||||
const updatedSearches: { [searchTerm: string]: QortalMetadata[] } = {};
|
||||
|
||||
for (const [term, items] of Object.entries(list.searches)) {
|
||||
updatedSearches[term] = items.filter(
|
||||
(item) => !names.includes(item.name)
|
||||
);
|
||||
}
|
||||
|
||||
updatedSearchCache[listName] = {
|
||||
...list,
|
||||
searches: updatedSearches,
|
||||
};
|
||||
}
|
||||
|
||||
return { searchCache: updatedSearchCache };
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import {create} from "zustand";
|
||||
import { create } from "zustand";
|
||||
import { QortalMetadata } from "../types/interfaces/resources";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
|
||||
interface ListsState {
|
||||
[listName: string]: {
|
||||
name: string;
|
||||
@@ -15,15 +14,16 @@ interface ListStore {
|
||||
|
||||
// CRUD Operations
|
||||
addList: (name: string, items: QortalMetadata[]) => void;
|
||||
removeFromList: (name: string, length: number)=> void;
|
||||
removeFromList: (name: string, length: number) => void;
|
||||
addItem: (listName: string, item: QortalMetadata) => void;
|
||||
addItems: (listName: string, items: QortalMetadata[]) => void;
|
||||
addItems: (listName: string, items: QortalMetadata[]) => void;
|
||||
updateItem: (listName: string, item: QortalMetadata) => void;
|
||||
deleteItem: (listName: string, itemKey: string) => void;
|
||||
deleteList: (listName: string) => void;
|
||||
|
||||
// Getter function
|
||||
getListByName: (listName: string) => QortalMetadata[]
|
||||
getListByName: (listName: string) => QortalMetadata[];
|
||||
filterOutItemsByNames: (names: string[]) => void;
|
||||
}
|
||||
|
||||
export const useListStore = create<ListStore>((set, get) => ({
|
||||
@@ -40,7 +40,13 @@ export const useListStore = create<ListStore>((set, get) => ({
|
||||
set((state) => ({
|
||||
lists: {
|
||||
...state.lists,
|
||||
[name]: { name, items: state.lists[name].items.slice(0, state.lists[name].items.length - length) }, // ✅ Store items as an array
|
||||
[name]: {
|
||||
name,
|
||||
items: state.lists[name].items.slice(
|
||||
0,
|
||||
state.lists[name].items.length - length
|
||||
),
|
||||
}, // ✅ Store items as an array
|
||||
},
|
||||
})),
|
||||
|
||||
@@ -50,7 +56,9 @@ export const useListStore = create<ListStore>((set, get) => ({
|
||||
|
||||
const itemKey = `${item.name}-${item.service}-${item.identifier}`;
|
||||
const existingItem = state.lists[listName].items.find(
|
||||
(existing) => `${existing.name}-${existing.service}-${existing.identifier}` === itemKey
|
||||
(existing) =>
|
||||
`${existing.name}-${existing.service}-${existing.identifier}` ===
|
||||
itemKey
|
||||
);
|
||||
|
||||
if (existingItem) return state; // Avoid duplicates
|
||||
@@ -65,52 +73,51 @@ export const useListStore = create<ListStore>((set, get) => ({
|
||||
},
|
||||
};
|
||||
}),
|
||||
addItems: (listName, items) =>
|
||||
set((state) => {
|
||||
|
||||
if (!state.lists[listName]) {
|
||||
console.warn(`List "${listName}" does not exist. Creating a new list.`);
|
||||
return {
|
||||
lists: {
|
||||
...state.lists,
|
||||
[listName]: { name: listName, items: [...items] }, // ✅ Create new list if missing
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ✅ Generate existing keys correctly
|
||||
const existingKeys = new Set(
|
||||
state.lists[listName].items.map(
|
||||
(item) => `${item.name}-${item.service}-${item.identifier}`
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
// ✅ Ensure we correctly compare identifiers
|
||||
const newItems = items.filter((item) => {
|
||||
const itemKey = `${item.name}-${item.service}-${item.identifier}`;
|
||||
const isDuplicate = existingKeys.has(itemKey);
|
||||
|
||||
return !isDuplicate; // ✅ Only keep items that are NOT in the existing list
|
||||
});
|
||||
|
||||
|
||||
if (newItems.length === 0) {
|
||||
console.warn("No new items were added because they were all considered duplicates.");
|
||||
return state; // ✅ Prevent unnecessary re-renders if no changes
|
||||
}
|
||||
|
||||
return {
|
||||
lists: {
|
||||
...state.lists,
|
||||
[listName]: {
|
||||
...state.lists[listName],
|
||||
items: [...state.lists[listName].items, ...newItems], // ✅ Append only new items
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
addItems: (listName, items) =>
|
||||
set((state) => {
|
||||
if (!state.lists[listName]) {
|
||||
console.warn(`List "${listName}" does not exist. Creating a new list.`);
|
||||
return {
|
||||
lists: {
|
||||
...state.lists,
|
||||
[listName]: { name: listName, items: [...items] }, // ✅ Create new list if missing
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ✅ Generate existing keys correctly
|
||||
const existingKeys = new Set(
|
||||
state.lists[listName].items.map(
|
||||
(item) => `${item.name}-${item.service}-${item.identifier}`
|
||||
)
|
||||
);
|
||||
|
||||
// ✅ Ensure we correctly compare identifiers
|
||||
const newItems = items.filter((item) => {
|
||||
const itemKey = `${item.name}-${item.service}-${item.identifier}`;
|
||||
const isDuplicate = existingKeys.has(itemKey);
|
||||
|
||||
return !isDuplicate; // ✅ Only keep items that are NOT in the existing list
|
||||
});
|
||||
|
||||
if (newItems.length === 0) {
|
||||
console.warn(
|
||||
"No new items were added because they were all considered duplicates."
|
||||
);
|
||||
return state; // ✅ Prevent unnecessary re-renders if no changes
|
||||
}
|
||||
|
||||
return {
|
||||
lists: {
|
||||
...state.lists,
|
||||
[listName]: {
|
||||
...state.lists[listName],
|
||||
items: [...state.lists[listName].items, ...newItems], // ✅ Append only new items
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
updateItem: (listName, item) =>
|
||||
set((state) => {
|
||||
if (!state.lists[listName]) return state;
|
||||
@@ -123,7 +130,8 @@ export const useListStore = create<ListStore>((set, get) => ({
|
||||
[listName]: {
|
||||
...state.lists[listName],
|
||||
items: state.lists[listName].items.map((existing) =>
|
||||
`${existing.name}-${existing.service}-${existing.identifier}` === itemKey
|
||||
`${existing.name}-${existing.service}-${existing.identifier}` ===
|
||||
itemKey
|
||||
? item // ✅ Update item
|
||||
: existing
|
||||
),
|
||||
@@ -142,7 +150,8 @@ export const useListStore = create<ListStore>((set, get) => ({
|
||||
[listName]: {
|
||||
...state.lists[listName],
|
||||
items: state.lists[listName].items.filter(
|
||||
(item) => `${item.name}-${item.service}-${item.identifier}` !== itemKey
|
||||
(item) =>
|
||||
`${item.name}-${item.service}-${item.identifier}` !== itemKey
|
||||
), // ✅ Remove from array
|
||||
},
|
||||
},
|
||||
@@ -160,4 +169,16 @@ export const useListStore = create<ListStore>((set, get) => ({
|
||||
}),
|
||||
|
||||
getListByName: (listName) => get().lists[listName]?.items || [], // ✅ Get a list by name
|
||||
filterOutItemsByNames: (names) =>
|
||||
set((state) => {
|
||||
const updatedLists: ListsState = {};
|
||||
|
||||
for (const [listName, listData] of Object.entries(state.lists)) {
|
||||
updatedLists[listName] = {
|
||||
...listData,
|
||||
items: listData.items.filter((item) => !names.includes(item.name)),
|
||||
};
|
||||
}
|
||||
return { lists: updatedLists };
|
||||
}),
|
||||
}));
|
||||
|
||||
139
src/state/multiplePublish.ts
Normal file
139
src/state/multiplePublish.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { create } from 'zustand';
|
||||
import { ResourceToPublish } from '../types/qortalRequests/types';
|
||||
import { QortalGetMetadata, Service } from '../types/interfaces/resources';
|
||||
|
||||
|
||||
interface MultiplePublishState {
|
||||
resources: ResourceToPublish[];
|
||||
failedResources: QortalGetMetadata[];
|
||||
isPublishing: boolean;
|
||||
resolveCallback?: (result: QortalGetMetadata[]) => void;
|
||||
rejectCallback?: (error: Error) => void;
|
||||
|
||||
setPublishResources: (resources: ResourceToPublish[]) => void;
|
||||
setFailedPublishResources: (resources: QortalGetMetadata[]) => void;
|
||||
setIsPublishing: (value: boolean) => void;
|
||||
setCompletionResolver: (resolver: (result: QortalGetMetadata[]) => void) => void;
|
||||
setRejectionResolver: (resolver: (reject: Error) => void) => void;
|
||||
complete: (result: any) => void;
|
||||
reject: (Error: Error) => void;
|
||||
reset: () => void;
|
||||
setError: (message: string | null)=> void
|
||||
error: string | null
|
||||
isLoading: boolean
|
||||
setIsLoading: (val: boolean)=> void
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
resources: [],
|
||||
failedResources: [],
|
||||
isPublishing: false,
|
||||
resolveCallback: undefined,
|
||||
rejectCallback: undefined,
|
||||
error: "",
|
||||
isLoading: false
|
||||
};
|
||||
|
||||
export const useMultiplePublishStore = create<MultiplePublishState>((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
setPublishResources: (resources) => {
|
||||
set({ resources, isPublishing: true });
|
||||
},
|
||||
setFailedPublishResources: (resources) => {
|
||||
set({ failedResources: resources });
|
||||
},
|
||||
setIsPublishing: (value) => {
|
||||
set({ isPublishing: value });
|
||||
},
|
||||
setIsLoading: (value) => {
|
||||
set({ isLoading: value });
|
||||
},
|
||||
setCompletionResolver: (resolver) => {
|
||||
set({ resolveCallback: resolver });
|
||||
},
|
||||
setRejectionResolver: (reject) => {
|
||||
set({ rejectCallback: reject });
|
||||
},
|
||||
complete: (result) => {
|
||||
const resolver = get().resolveCallback;
|
||||
if (resolver) resolver(result);
|
||||
set({ resolveCallback: undefined, isPublishing: false });
|
||||
},
|
||||
reject: (result) => {
|
||||
const resolver = get().rejectCallback;
|
||||
if (resolver) resolver(result);
|
||||
set({ resolveCallback: undefined, isPublishing: false });
|
||||
},
|
||||
setError: (message) => {
|
||||
set({ error: message });
|
||||
},
|
||||
|
||||
reset: () => set(initialState),
|
||||
}));
|
||||
|
||||
export type PublishLocation = {
|
||||
name: string;
|
||||
identifier: string;
|
||||
service: Service;
|
||||
};
|
||||
|
||||
export type PublishStatus = {
|
||||
publishLocation: PublishLocation;
|
||||
chunks: number;
|
||||
totalChunks: number;
|
||||
processed: boolean;
|
||||
error?: {
|
||||
reason: string
|
||||
}
|
||||
retry: boolean
|
||||
filename: string
|
||||
};
|
||||
|
||||
type PublishStatusStore = {
|
||||
publishStatus: Record<string, PublishStatus>;
|
||||
getPublishStatusByKey: (key: string) => PublishStatus | undefined;
|
||||
setPublishStatusByKey: (key: string, update: Partial<PublishStatus>) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
export const usePublishStatusStore = create<PublishStatusStore>((set, get) => ({
|
||||
publishStatus: {},
|
||||
|
||||
getPublishStatusByKey: (key) => get().publishStatus[key],
|
||||
|
||||
setPublishStatusByKey: (key, update) => {
|
||||
const current = get().publishStatus;
|
||||
|
||||
const prev: PublishStatus = current[key] ?? {
|
||||
publishLocation: {
|
||||
name: '',
|
||||
identifier: '',
|
||||
service: 'DOCUMENT',
|
||||
processed: false,
|
||||
},
|
||||
chunks: 0,
|
||||
totalChunks: 0,
|
||||
processed: false,
|
||||
retry: false
|
||||
};
|
||||
|
||||
const newStatus: PublishStatus = {
|
||||
...prev,
|
||||
...update,
|
||||
publishLocation: {
|
||||
...prev.publishLocation,
|
||||
...(update.publishLocation ?? {}),
|
||||
},
|
||||
};
|
||||
|
||||
set({
|
||||
publishStatus: {
|
||||
...current,
|
||||
[key]: newStatus,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
reset: () => set({ publishStatus: {} })
|
||||
}));
|
||||
30
src/state/pip.ts
Normal file
30
src/state/pip.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
type PlayerMode = 'embedded' | 'floating' | 'none';
|
||||
|
||||
interface GlobalPlayerState {
|
||||
videoSrc: string | null;
|
||||
videoId: string,
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
location: string;
|
||||
mode: PlayerMode;
|
||||
setVideoState: (state: Partial<GlobalPlayerState>) => void;
|
||||
type: string,
|
||||
reset: ()=> void;
|
||||
}
|
||||
const initialState = {
|
||||
videoSrc: null,
|
||||
videoId: "",
|
||||
location: "",
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
type: 'video/mp4',
|
||||
mode: 'embedded' as const,
|
||||
};
|
||||
export const useGlobalPlayerStore = create<GlobalPlayerState>((set) => ({
|
||||
...initialState,
|
||||
setVideoState: (state) => set((prev) => ({ ...prev, ...state })),
|
||||
reset: () => set(initialState),
|
||||
|
||||
}));
|
||||
@@ -1,6 +1,14 @@
|
||||
import { create } from "zustand";
|
||||
import { QortalGetMetadata } from "../types/interfaces/resources";
|
||||
import { QortalGetMetadata, Service } from "../types/interfaces/resources";
|
||||
import { Resource } from "../hooks/useResources";
|
||||
import { RequestQueueWithPromise } from "../utils/queue";
|
||||
|
||||
export const requestQueueBuildFile = new RequestQueueWithPromise(
|
||||
1
|
||||
);
|
||||
export const requestQueueStatusFile = new RequestQueueWithPromise(
|
||||
2
|
||||
);
|
||||
|
||||
interface PublishCache {
|
||||
data: Resource | null;
|
||||
@@ -21,14 +29,29 @@ export type Status =
|
||||
| 'FAILED_TO_DOWNLOAD'
|
||||
| 'REFETCHING'
|
||||
| 'SEARCHING'
|
||||
| 'INITIAL'
|
||||
|
||||
export interface ResourceStatus {
|
||||
status: Status
|
||||
localChunkCount: number
|
||||
totalChunkCount: number
|
||||
percentLoaded: number
|
||||
|
||||
path?: string
|
||||
filename?: string
|
||||
}
|
||||
|
||||
interface GlobalDownloadEntry {
|
||||
interval: ReturnType<typeof setInterval> | null;
|
||||
timeout: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
interface ResourceStatusEntry {
|
||||
id: string;
|
||||
metadata: QortalGetMetadata;
|
||||
status: ResourceStatus;
|
||||
path?: string;
|
||||
filename?: string;
|
||||
}
|
||||
interface PublishState {
|
||||
publishes: Record<string, PublishCache>;
|
||||
resourceStatus: Record<string, ResourceStatus | null>;
|
||||
@@ -38,11 +61,23 @@ interface PublishState {
|
||||
setPublish: (qortalGetMetadata: QortalGetMetadata, data: Resource | null, customExpiry?: number) => void;
|
||||
clearExpiredPublishes: () => void;
|
||||
publishExpiryDuration: number; // Default expiry duration
|
||||
getAllResourceStatus: () => ResourceStatusEntry[];
|
||||
startGlobalDownload: (
|
||||
resourceId: string,
|
||||
metadata: QortalGetMetadata,
|
||||
retryAttempts: number,
|
||||
path?: string,
|
||||
filename?: string
|
||||
) => void;
|
||||
stopGlobalDownload: (resourceId: string) => void;
|
||||
globalDownloads: Record<string, GlobalDownloadEntry>;
|
||||
|
||||
}
|
||||
|
||||
export const usePublishStore = create<PublishState>((set, get) => ({
|
||||
resourceStatus: {},
|
||||
publishes: {},
|
||||
globalDownloads: {},
|
||||
publishExpiryDuration: 5 * 60 * 1000, // Default expiry: 5 minutes
|
||||
|
||||
getPublish: (qortalGetMetadata, ignoreExpire = false) => {
|
||||
@@ -91,10 +126,13 @@ export const usePublishStore = create<PublishState>((set, get) => ({
|
||||
}));
|
||||
},
|
||||
getResourceStatus: (resourceId) => {
|
||||
if(!resourceId) return null;
|
||||
const status = get().resourceStatus[resourceId];
|
||||
return status || null;
|
||||
},
|
||||
if (!resourceId) return null;
|
||||
const status = get().resourceStatus[resourceId];
|
||||
if (!status) return null;
|
||||
|
||||
const { path, filename, ...rest } = status;
|
||||
return rest;
|
||||
},
|
||||
clearExpiredPublishes: () => {
|
||||
set((state) => {
|
||||
const now = Date.now();
|
||||
@@ -104,4 +142,231 @@ export const usePublishStore = create<PublishState>((set, get) => ({
|
||||
return { publishes: updatedPublishes };
|
||||
});
|
||||
},
|
||||
getAllResourceStatus: () => {
|
||||
const { resourceStatus } = get();
|
||||
|
||||
return Object.entries(resourceStatus)
|
||||
.filter(([_, status]) => status !== null)
|
||||
.map(([id, status]) => {
|
||||
const parts = id.split('-');
|
||||
const service = parts[0] as Service;
|
||||
const name = parts[1] || '';
|
||||
const identifier = parts.length > 2 ? parts.slice(2).join('-') : '';
|
||||
|
||||
const { path, filename, ...rest } = status!; // extract path from old ResourceStatus
|
||||
|
||||
return {
|
||||
id,
|
||||
metadata: {
|
||||
service,
|
||||
name,
|
||||
identifier,
|
||||
},
|
||||
status: rest,
|
||||
path,
|
||||
filename
|
||||
};
|
||||
});
|
||||
},
|
||||
startGlobalDownload: (
|
||||
resourceId,
|
||||
metadata,
|
||||
retryAttempts,
|
||||
path,
|
||||
filename
|
||||
) => {
|
||||
if (get().globalDownloads[resourceId]) return;
|
||||
|
||||
const { service, name, identifier } = metadata;
|
||||
const setResourceStatus = get().setResourceStatus;
|
||||
const stopGlobalDownload = get().stopGlobalDownload;
|
||||
const getResourceStatus = get().getResourceStatus;
|
||||
|
||||
const intervalMap: Record<string, any> = {};
|
||||
const timeoutMap: Record<string, any> = {};
|
||||
const statusMap: Record<string, ResourceStatus | null> = {};
|
||||
|
||||
statusMap[resourceId] = getResourceStatus(resourceId);
|
||||
|
||||
let isCalling = false;
|
||||
let percentLoaded = 0;
|
||||
let timer = 29;
|
||||
let tries = 0;
|
||||
let calledFirstTime = false;
|
||||
let isPaused = false
|
||||
const callFunction = async (build?: boolean, isRecalling?: boolean) => {
|
||||
try {
|
||||
|
||||
if ((isCalling || isPaused) && !build) return;
|
||||
isCalling = true;
|
||||
statusMap[resourceId] = getResourceStatus(resourceId);
|
||||
|
||||
if (statusMap[resourceId]?.status === 'READY') {
|
||||
if (intervalMap[resourceId]) clearInterval(intervalMap[resourceId]);
|
||||
if (timeoutMap[resourceId]) clearTimeout(timeoutMap[resourceId]);
|
||||
intervalMap[resourceId] = null;
|
||||
timeoutMap[resourceId] = null;
|
||||
stopGlobalDownload(resourceId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRecalling) {
|
||||
setResourceStatus(
|
||||
{ service, name, identifier },
|
||||
{
|
||||
status: "SEARCHING",
|
||||
localChunkCount: 0,
|
||||
totalChunkCount: 0,
|
||||
percentLoaded: 0,
|
||||
path: path || "",
|
||||
filename: filename || ""
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
let res;
|
||||
|
||||
if (!build) {
|
||||
res = await requestQueueStatusFile.enqueue(()=> qortalRequest({
|
||||
action: "GET_QDN_RESOURCE_STATUS",
|
||||
name: name,
|
||||
service: service,
|
||||
identifier: identifier,
|
||||
}))
|
||||
// res = await resCall.json();
|
||||
setResourceStatus({ service, name, identifier }, { ...res });
|
||||
|
||||
if (tries > retryAttempts) {
|
||||
if (intervalMap[resourceId]) clearInterval(intervalMap[resourceId]);
|
||||
if (timeoutMap[resourceId]) clearTimeout(timeoutMap[resourceId]);
|
||||
intervalMap[resourceId] = null;
|
||||
timeoutMap[resourceId] = null;
|
||||
stopGlobalDownload(resourceId);
|
||||
setResourceStatus({ service, name, identifier }, {
|
||||
...res,
|
||||
status: "FAILED_TO_DOWNLOAD"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
if (build || (calledFirstTime === false && res?.status !== "READY")) {
|
||||
calledFirstTime = true;
|
||||
isCalling = true;
|
||||
const url = `/arbitrary/resource/properties/${service}/${name}/${identifier}?build=true`;
|
||||
// const resCall = await fetch(url, {
|
||||
// method: "GET",
|
||||
// headers: { "Content-Type": "application/json" },
|
||||
// });
|
||||
const resCall = await requestQueueBuildFile.enqueue(()=> fetch(url, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}))
|
||||
res = await resCall.json();
|
||||
isPaused = false
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
if (res.localChunkCount) {
|
||||
if (res.percentLoaded) {
|
||||
if (
|
||||
res.percentLoaded === percentLoaded &&
|
||||
res.percentLoaded !== 100
|
||||
) {
|
||||
timer -= 5;
|
||||
} else {
|
||||
timer = 29;
|
||||
}
|
||||
|
||||
if (timer < 0) {
|
||||
timer = 29;
|
||||
isCalling = true;
|
||||
isPaused = true
|
||||
tries += 1;
|
||||
setResourceStatus({ service, name, identifier }, {
|
||||
...res,
|
||||
status: "REFETCHING"
|
||||
});
|
||||
|
||||
timeoutMap[resourceId] = setTimeout(() => {
|
||||
|
||||
callFunction(true, true);
|
||||
}, 10000);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
percentLoaded = res.percentLoaded;
|
||||
}
|
||||
|
||||
setResourceStatus({ service, name, identifier }, { ...res });
|
||||
}
|
||||
|
||||
if (res?.status === "READY") {
|
||||
if (intervalMap[resourceId]) clearInterval(intervalMap[resourceId]);
|
||||
if (timeoutMap[resourceId]) clearTimeout(timeoutMap[resourceId]);
|
||||
intervalMap[resourceId] = null;
|
||||
timeoutMap[resourceId] = null;
|
||||
stopGlobalDownload(resourceId);
|
||||
setResourceStatus({ service, name, identifier }, { ...res });
|
||||
return;
|
||||
}
|
||||
|
||||
if (res?.status === "DOWNLOADED") {
|
||||
res = await qortalRequest({
|
||||
action: "GET_QDN_RESOURCE_STATUS",
|
||||
name: name,
|
||||
service: service,
|
||||
identifier: identifier,
|
||||
build: true
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error during resource fetch:", error);
|
||||
} finally {
|
||||
isCalling = false;
|
||||
}
|
||||
};
|
||||
|
||||
callFunction();
|
||||
|
||||
intervalMap[resourceId] = setInterval(() => {
|
||||
callFunction(false, true);
|
||||
}, 5000);
|
||||
|
||||
set((state) => ({
|
||||
globalDownloads: {
|
||||
...state.globalDownloads,
|
||||
[resourceId]: {
|
||||
interval: intervalMap[resourceId],
|
||||
timeout: timeoutMap[resourceId],
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
|
||||
|
||||
stopGlobalDownload: (resourceId) => {
|
||||
const entry = get().globalDownloads[resourceId];
|
||||
if (entry) {
|
||||
if (entry.interval !== null) clearInterval(entry.interval);
|
||||
if (entry.timeout !== null) clearTimeout(entry.timeout);
|
||||
set((state) => {
|
||||
const updated = { ...state.globalDownloads };
|
||||
delete updated[resourceId];
|
||||
return { globalDownloads: updated };
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
}));
|
||||
|
||||
131
src/state/video.ts
Normal file
131
src/state/video.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { get as idbGet, set as idbSet, del as idbDel, keys as idbKeys } from 'idb-keyval';
|
||||
|
||||
const EXPIRY_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
const PROGRESS_UPDATE_INTERVAL = 5 * 1000;
|
||||
const lastSavedTimestamps: Record<string, number> = {};
|
||||
|
||||
const indexedDBWithExpiry = {
|
||||
getItem: async (key: string) => {
|
||||
const value = await idbGet(key);
|
||||
if (!value) return null;
|
||||
|
||||
const now = Date.now();
|
||||
const expired =
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'expiresAt' in value &&
|
||||
typeof value.expiresAt === 'number' &&
|
||||
now > value.expiresAt;
|
||||
|
||||
return expired ? null : value.data ?? value;
|
||||
},
|
||||
setItem: async (key: string, value: any) => {
|
||||
await idbSet(key, {
|
||||
data: value,
|
||||
expiresAt: Date.now() + EXPIRY_DURATION,
|
||||
});
|
||||
},
|
||||
removeItem: async (key: string) => {
|
||||
await idbDel(key);
|
||||
},
|
||||
};
|
||||
|
||||
type PlaybackSettings = {
|
||||
playbackRate: number;
|
||||
volume: number;
|
||||
};
|
||||
|
||||
type PlaybackStore = {
|
||||
playbackSettings: PlaybackSettings;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
getPersistedPlaybackRate: () => number;
|
||||
getPersistedVolume: () => number;
|
||||
};
|
||||
|
||||
export const useVideoStore = create<PlaybackStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
playbackSettings: {
|
||||
playbackRate: 1.0,
|
||||
volume: 1.0,
|
||||
},
|
||||
setPlaybackRate: (rate) =>
|
||||
set((state) => ({
|
||||
playbackSettings: { ...state.playbackSettings, playbackRate: rate },
|
||||
})),
|
||||
setVolume: (volume) =>
|
||||
set((state) => ({
|
||||
playbackSettings: { ...state.playbackSettings, volume },
|
||||
})),
|
||||
getPersistedPlaybackRate: () => get().playbackSettings.playbackRate,
|
||||
getPersistedVolume: () => get().playbackSettings.volume,
|
||||
}),
|
||||
{
|
||||
name: 'video-playback-settings',
|
||||
partialize: (state) => ({ playbackSettings: state.playbackSettings }),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
type ProgressStore = {
|
||||
progressMap: Record<string, number>;
|
||||
setProgress: (id: string, time: number) => void;
|
||||
getProgress: (id: string) => number;
|
||||
clearOldProgress: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const useProgressStore = create<ProgressStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
progressMap: {},
|
||||
setProgress: (id, time) => {
|
||||
const now = Date.now();
|
||||
if (now - (lastSavedTimestamps[id] || 0) >= PROGRESS_UPDATE_INTERVAL) {
|
||||
lastSavedTimestamps[id] = now;
|
||||
set((state) => ({
|
||||
progressMap: {
|
||||
...state.progressMap,
|
||||
[id]: time,
|
||||
},
|
||||
}));
|
||||
}
|
||||
},
|
||||
getProgress: (id) => get().progressMap[id] || 0,
|
||||
clearOldProgress: async () => {
|
||||
const now = Date.now();
|
||||
const allKeys = await idbKeys();
|
||||
for (const key of allKeys) {
|
||||
const value = await idbGet(key as string);
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'expiresAt' in value &&
|
||||
typeof value.expiresAt === 'number' &&
|
||||
now > value.expiresAt
|
||||
) {
|
||||
await idbDel(key as string);
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'video-progress-map',
|
||||
storage: indexedDBWithExpiry,
|
||||
partialize: (state) => ({ progressMap: state.progressMap }),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
interface IsPlayingState {
|
||||
isPlaying: boolean;
|
||||
setIsPlaying: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const useIsPlaying = create<IsPlayingState>((set) => ({
|
||||
isPlaying: false,
|
||||
setIsPlaying: (value) => set({ isPlaying: value }),
|
||||
}));
|
||||
@@ -51,7 +51,7 @@ export type Service =
|
||||
| "VOICE_PRIVATE"
|
||||
| "DOCUMENT_PRIVATE"
|
||||
| "MAIL_PRIVATE"
|
||||
| "MESSAGE_PRIVATE";
|
||||
| "MESSAGE_PRIVATE" | 'AUTO_UPDATE';
|
||||
|
||||
|
||||
export interface QortalMetadata {
|
||||
@@ -99,4 +99,9 @@ export interface QortalMetadata {
|
||||
reverse?: boolean;
|
||||
mode?: 'ALL' | 'LATEST'
|
||||
}
|
||||
|
||||
export interface QortalPreloadedParams {
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Service } from "../interfaces/resources";
|
||||
import {
|
||||
Coin,
|
||||
ConfirmationStatus,
|
||||
@@ -8,7 +9,6 @@ import {
|
||||
ForeignCoin,
|
||||
ResourcePointer,
|
||||
ResourceToPublish,
|
||||
Service,
|
||||
TxType,
|
||||
} from "./types";
|
||||
|
||||
@@ -615,12 +615,29 @@ export interface ShowActionsQortalRequest extends BaseRequest {
|
||||
action: 'SHOW_ACTIONS'
|
||||
}
|
||||
|
||||
export interface ScreenOrientation extends BaseRequest {
|
||||
action: 'SCREEN_ORIENTATION',
|
||||
mode: | "portrait"
|
||||
| "landscape"
|
||||
| "portrait-primary"
|
||||
| "portrait-secondary"
|
||||
| "landscape-primary"
|
||||
| "landscape-secondary" | "unlock";
|
||||
}
|
||||
|
||||
export interface SignTransactionQortalRequest extends BaseRequest {
|
||||
action: 'SIGN_TRANSACTION'
|
||||
unsignedBytes: string
|
||||
process?: boolean
|
||||
}
|
||||
|
||||
export interface GetNodeStatusQortalRequest extends BaseRequest {
|
||||
action: 'GET_NODE_STATUS'
|
||||
}
|
||||
export interface GetNodeInfoQortalRequest extends BaseRequest {
|
||||
action: 'GET_NODE_INFO'
|
||||
}
|
||||
|
||||
export interface CreateAndCopyEmbedLinkQortalRequest extends BaseRequest {
|
||||
action: 'CREATE_AND_COPY_EMBED_LINK'
|
||||
type: string
|
||||
|
||||
@@ -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,23 @@ export function base64ToObject(base64: string){
|
||||
const toObject = uint8ArrayToObject(toUint);
|
||||
|
||||
return toObject
|
||||
}
|
||||
}
|
||||
|
||||
export const base64ToBlobUrl = (base64: string, mimeType = 'text/vtt'): string => {
|
||||
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 '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { base64ToObject } from './base64';
|
||||
import { Buffer } from 'buffer'
|
||||
|
||||
// Polyfill Buffer first
|
||||
if (!(globalThis as any).Buffer) {
|
||||
;(globalThis as any).Buffer = Buffer
|
||||
}
|
||||
|
||||
async function getBloomFilter() {
|
||||
const { BloomFilter } = await import('bloom-filters')
|
||||
return BloomFilter
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export async function generateBloomFilterBase64(values: string[]) {
|
||||
const maxItems = 100
|
||||
if (values.length > maxItems) {
|
||||
throw new Error(`Max ${maxItems} items allowed`)
|
||||
}
|
||||
|
||||
// Create filter for the expected number of items and desired false positive rate
|
||||
const BloomFilter = await getBloomFilter()
|
||||
const bloom = BloomFilter.create(values.length, 0.025) // ~0.04% FPR
|
||||
|
||||
for (const value of values) {
|
||||
bloom.add(value)
|
||||
}
|
||||
|
||||
// Convert filter to JSON, then to base64
|
||||
const json = bloom.saveAsJSON()
|
||||
const jsonString = JSON.stringify(json)
|
||||
const base64 = Buffer.from(jsonString).toString('base64')
|
||||
|
||||
const size = Buffer.byteLength(jsonString)
|
||||
if (size > 238) {
|
||||
throw new Error(`Bloom filter exceeds 230 bytes: ${size}`)
|
||||
}
|
||||
|
||||
return base64
|
||||
}
|
||||
|
||||
|
||||
export async function isInsideBloom(base64Bloom: string, userPublicKey: string) {
|
||||
const base64ToJson = base64ToObject(base64Bloom)
|
||||
const BloomFilter = await getBloomFilter()
|
||||
const bloom = BloomFilter.fromJSON(base64ToJson)
|
||||
|
||||
|
||||
return bloom.has(userPublicKey);
|
||||
}
|
||||
|
||||
@@ -129,6 +129,33 @@ export async function buildSearchPrefix(
|
||||
: `${appHash}-${entityPrefix}-`; // ✅ Global search for entity type
|
||||
}
|
||||
|
||||
export async function buildLooseSearchPrefix(
|
||||
entityType: string,
|
||||
parentId?: string | null
|
||||
): Promise<string> {
|
||||
// Hash entity type (6 chars)
|
||||
const entityPrefix: string = await hashWord(
|
||||
entityType,
|
||||
EnumCollisionStrength.ENTITY_LABEL,
|
||||
""
|
||||
);
|
||||
|
||||
let parentRef = "";
|
||||
if (parentId === null) {
|
||||
parentRef = "00000000000000"; // for true root entities
|
||||
} else if (parentId) {
|
||||
parentRef = await hashWord(
|
||||
parentId,
|
||||
EnumCollisionStrength.PARENT_REF,
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
return parentRef
|
||||
? `${entityPrefix}-${parentRef}-` // for nested entity searches
|
||||
: `${entityPrefix}-`; // global entity type prefix
|
||||
}
|
||||
|
||||
// Function to generate IDs dynamically with `publicSalt`
|
||||
export async function buildIdentifier(
|
||||
appName: string,
|
||||
@@ -166,6 +193,33 @@ export async function buildIdentifier(
|
||||
return `${appHash}-${entityPrefix}-${parentRef}-${entityUid}-${IDENTIFIER_BUILDER_VERSION}`;
|
||||
}
|
||||
|
||||
export async function buildLooseIdentifier(
|
||||
entityType: string,
|
||||
parentId?: string | null
|
||||
): Promise<string> {
|
||||
// 4-char hash for entity type
|
||||
const entityPrefix: string = await hashWord(
|
||||
entityType,
|
||||
EnumCollisionStrength.ENTITY_LABEL,
|
||||
""
|
||||
);
|
||||
|
||||
// Generate 8-12 character random uid (depends on uid.rnd() settings)
|
||||
const entityUid = uid.rnd();
|
||||
|
||||
// Optional hashed parent ref
|
||||
let parentRef = '';
|
||||
if (parentId) {
|
||||
parentRef = await hashWord(
|
||||
parentId,
|
||||
EnumCollisionStrength.PARENT_REF,
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
return `${entityPrefix}${parentRef ? `-${parentRef}` : ''}-${entityUid}${IDENTIFIER_BUILDER_VERSION ? `-${IDENTIFIER_BUILDER_VERSION}` : ''}`;
|
||||
}
|
||||
|
||||
export const createSymmetricKeyAndNonce = () => {
|
||||
const messageKey = new Uint8Array(32); // 32 bytes for the symmetric key
|
||||
crypto.getRandomValues(messageKey);
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
export const createAvatarLink = (qortalName: string)=> {
|
||||
export const createAvatarLink = (qortalName: string): string => {
|
||||
if (!qortalName?.trim()) return '';
|
||||
|
||||
return `/arbitrary/THUMBNAIL/${encodeURIComponent(qortalName)}/qortal_avatar?async=true`
|
||||
}
|
||||
|
||||
const removeTrailingSlash = (str: string) => str.replace(/\/$/, '');
|
||||
|
||||
export const createQortalLink = (type: 'APP' | 'WEBSITE', appName: string, path: string) => {
|
||||
export const createQortalLink = (
|
||||
type: 'APP' | 'WEBSITE',
|
||||
appName: string,
|
||||
path: string
|
||||
) => {
|
||||
const encodedAppName = encodeURIComponent(appName);
|
||||
let link = `qortal://${type}/${encodedAppName}`;
|
||||
|
||||
let link = 'qortal://' + type + '/' + appName
|
||||
if(path && path.startsWith('/')){
|
||||
link = link + removeTrailingSlash(path)
|
||||
}
|
||||
if(path && !path.startsWith('/')){
|
||||
link = link + '/' + removeTrailingSlash(path)
|
||||
}
|
||||
return link
|
||||
};
|
||||
if (path) {
|
||||
link += path.startsWith('/')
|
||||
? removeTrailingSlash(path)
|
||||
: '/' + removeTrailingSlash(path);
|
||||
}
|
||||
|
||||
return link;
|
||||
};
|
||||
@@ -16,7 +16,7 @@ export function processText(input: string): string {
|
||||
const link = document.createElement('span');
|
||||
link.setAttribute('data-url', part);
|
||||
link.textContent = part;
|
||||
link.style.color = 'var(--code-block-text-color)';
|
||||
link.style.color = '#8ab4f8';
|
||||
link.style.textDecoration = 'underline';
|
||||
link.style.cursor = 'pointer';
|
||||
fragment.appendChild(link);
|
||||
|
||||
@@ -25,4 +25,29 @@ export function formatTimestamp(timestamp: number): string {
|
||||
export function oneMonthAgo(){
|
||||
const oneMonthAgoTimestamp = dayjs().subtract(1, "month").valueOf();
|
||||
return oneMonthAgoTimestamp
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
seconds = Math.floor(seconds);
|
||||
const minutes: number | string = Math.floor(seconds / 60);
|
||||
let hours: number | string = Math.floor(minutes / 60);
|
||||
|
||||
let remainingSeconds: number | string = seconds % 60;
|
||||
let remainingMinutes: number | string = minutes % 60;
|
||||
|
||||
if (remainingSeconds < 10) {
|
||||
remainingSeconds = "0" + remainingSeconds;
|
||||
}
|
||||
|
||||
if (remainingMinutes < 10) {
|
||||
remainingMinutes = "0" + remainingMinutes;
|
||||
}
|
||||
|
||||
if (hours === 0) {
|
||||
hours = "";
|
||||
} else {
|
||||
hours = hours + ":";
|
||||
}
|
||||
|
||||
return hours + remainingMinutes + ":" + remainingSeconds;
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
|
||||
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -10,5 +10,6 @@ export default defineConfig({
|
||||
'@mui/system',
|
||||
'@emotion/react',
|
||||
'@emotion/styled',
|
||||
'react-dom', 'react-router-dom'
|
||||
],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user