Merge pull request #2 from Qortal/feature/video-player

Feature/video player
This commit is contained in:
Phillip
2025-08-01 12:54:16 +03:00
committed by GitHub
73 changed files with 9896 additions and 1371 deletions

1917
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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.');
})();

View 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;

View File

@@ -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(() => {

View File

@@ -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);

View 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);

View File

@@ -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>
))}

View File

@@ -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);

View File

@@ -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)}</>;

View 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)}</>;
};

View File

@@ -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);

View 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>
);
};

View 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
},
},
},
}}
/>
);
}

View 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>
</>
)}
</>
);
};

View 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>
);
};

View 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);

View 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; // Dont 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>
);
})}
</>
);
};

View 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>
</>
);
};

View 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>
);
};

View 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);
`;

View 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>
</>
);
};

View 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}
/>
);
};

View 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 (01)
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)
};
};

View 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;
};

View File

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

View File

@@ -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>
);
};

View File

@@ -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>

View 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
};
})
);

View File

@@ -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
View 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]
);
};

View 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]
);
};

View 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>
);
};

View File

@@ -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
View 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;
};

View 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,
]
);
};

View 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
View 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
}

View File

@@ -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
]);
};

View File

@@ -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,
]
);
};

View File

@@ -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
View 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 (09)",
"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 lindex",
"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 % (09)",
"reload_video": "Recharger la vidéo (R)",
"play_pause": "Lecture/Pause (Barre despace)"
},
"index": {
"title": "Gestionnaire dindex",
"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 lindex...",
"published_index": "Index publié avec succès",
"failed_index": "Échec de la publication de lindex",
"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 dindex multiples",
"multiple_description": "Les index supplémentaires réduisent les frais de publication, mais auront moins dimpact 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% (09)",
"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% (09)",
"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% 增量跳转视频 (09)",
"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
View 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;

View 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": "فشل النشر. تتم المحاولة مرة أخرى..."
}
}

View 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 (09)",
"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..."
}
}

View 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..."
}
}

View 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..."
}
}

View 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 lindex",
"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 % (09)",
"reload_video": "Recharger la vidéo (R)",
"play_pause": "Lecture/Pause (Barre despace)"
},
"index": {
"title": "Gestionnaire dindex",
"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 lindex...",
"published_index": "Index publié avec succès",
"failed_index": "Échec de la publication de lindex",
"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 dindex multiples",
"multiple_description": "Les index supplémentaires réduisent les frais de publication, mais auront moins dimpact 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..."
}
}

View 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% (09)",
"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..."
}
}

View 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": "公開に失敗しました。再試行中..."
}
}

View 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% (09)",
"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": "Публикация не удалась. Повторная попытка..."
}
}

View 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% 增量跳转视频 (09)",
"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
View 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;
},
};

View File

@@ -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;
}

View File

@@ -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'

View File

@@ -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`,
}),
}));

View File

@@ -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 };
}),
}));

View File

@@ -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 };
}),
}));

View 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
View 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),
}));

View File

@@ -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
View 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 }),
}));

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 =

View File

@@ -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 '';
}
};

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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;
};

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -9,6 +9,8 @@
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}

View File

@@ -10,5 +10,6 @@ export default defineConfig({
'@mui/system',
'@emotion/react',
'@emotion/styled',
'react-dom', 'react-router-dom'
],
});