From 945f5b7d8370ebe885216668350056400575948b Mon Sep 17 00:00:00 2001 From: PhilReact Date: Mon, 16 Jun 2025 04:50:22 +0300 Subject: [PATCH] added timeline actions --- .../VideoPlayer/SubtitleManager.tsx | 18 ++--- .../VideoPlayer/TimelineActionsComponent.tsx | 72 +++++++++++++++++++ .../VideoPlayer/VideoPlayer-styles.ts | 6 +- src/components/VideoPlayer/VideoPlayer.tsx | 34 ++++++++- .../VideoPlayer/useVideoPlayerController.tsx | 13 +++- src/hooks/useIdentifiers.tsx | 4 ++ src/index.ts | 1 + src/utils/encryption.ts | 54 ++++++++++++++ 8 files changed, 185 insertions(+), 17 deletions(-) create mode 100644 src/components/VideoPlayer/TimelineActionsComponent.tsx diff --git a/src/components/VideoPlayer/SubtitleManager.tsx b/src/components/VideoPlayer/SubtitleManager.tsx index 21fa586..e117032 100644 --- a/src/components/VideoPlayer/SubtitleManager.tsx +++ b/src/components/VideoPlayer/SubtitleManager.tsx @@ -58,13 +58,15 @@ import { showLoading, showSuccess, } from "../../utils/toast"; -interface SubtitleManagerProps { + + +export interface SubtitleManagerProps { qortalMetadata: QortalGetMetadata; close: () => void; open: boolean; onSelect: (subtitle: SubtitlePublishedData) => void; subtitleBtnRef: any; - currentSubTrack: null | string; + currentSubTrack: null | string } export interface Subtitle { language: string | null; @@ -123,7 +125,7 @@ const SubtitleManagerComponent = ({ setIsLoading(true); const videoId = `${qortalMetadata?.service}-${qortalMetadata?.name}-${qortalMetadata?.identifier}`; console.log("videoId", videoId); - const postIdSearch = await identifierOperations.buildSearchPrefix( + const postIdSearch = await identifierOperations.buildLooseSearchPrefix( ENTITY_SUBTITLE, videoId ); @@ -179,16 +181,16 @@ const SubtitleManagerComponent = ({ try { const videoId = `${qortalMetadata?.service}-${qortalMetadata?.name}-${qortalMetadata?.identifier}`; - const identifier = await identifierOperations.buildIdentifier( - ENTITY_SUBTITLE, - videoId - ); + const name = auth?.name; - console.log("identifier2", identifier); if (!name) return; const resources: ResourceToPublish[] = []; const tempResources: { qortalMetadata: QortalMetadata; data: any }[] = []; for (const sub of subtitles) { + const identifier = await identifierOperations.buildLooseIdentifier( + ENTITY_SUBTITLE, + videoId + ); const data = { subtitleData: sub.base64, language: sub.language, diff --git a/src/components/VideoPlayer/TimelineActionsComponent.tsx b/src/components/VideoPlayer/TimelineActionsComponent.tsx new file mode 100644 index 0000000..c32677a --- /dev/null +++ b/src/components/VideoPlayer/TimelineActionsComponent.tsx @@ -0,0 +1,72 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { TimelineAction } from './VideoPlayer' +import { alpha, Box, ButtonBase, Popover, Typography } from '@mui/material' + +interface TimelineActionsComponentProps { +timelineActions: TimelineAction[] +progress: number +containerRef: any +seekTo: (time: number)=> void + +} + +const placementStyles: Record, 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}: TimelineActionsComponentProps) => { + const [isOpen, setIsOpen] = useState(true) + + const handleClick = useCallback((action: TimelineAction)=> { + if(action?.type === 'SEEK'){ + if(!action?.seekToTime) return + seekTo(action.seekToTime) + } else if(action?.type === 'CUSTOM'){ + if(action.onClick){ + action.onClick() + } + } + },[]) + + // Find the current matching action(s) + const activeActions = useMemo(() => { + return timelineActions.filter(action => { + return progress >= action.time && progress <= action.time + action.duration; + }); + }, [timelineActions, progress]); + + const hasActive = activeActions.length > 0; + + if (!hasActive) return null; // Don’t render unless active + return ( + <> + {activeActions.map((action, index) => { + const placement = (action.placement ?? 'TOP-RIGHT') as keyof typeof placementStyles; + + return ( + + + handleClick(action)}> + {action.label} + + + + ) + } )} + + ) +} diff --git a/src/components/VideoPlayer/VideoPlayer-styles.ts b/src/components/VideoPlayer/VideoPlayer-styles.ts index 903148d..69c74f2 100644 --- a/src/components/VideoPlayer/VideoPlayer-styles.ts +++ b/src/components/VideoPlayer/VideoPlayer-styles.ts @@ -17,11 +17,7 @@ export const VideoContainer = styled(Box)(({ theme }) => ({ })); export const VideoElement = styled("video")(({ theme }) => ({ - position: 'absolute', - top: 0, - bottom: 0, - right: 0, - left: 0, + background: "rgb(33, 33, 33)", "&:focus": { diff --git a/src/components/VideoPlayer/VideoPlayer.tsx b/src/components/VideoPlayer/VideoPlayer.tsx index 73c18b1..6fffcc7 100644 --- a/src/components/VideoPlayer/VideoPlayer.tsx +++ b/src/components/VideoPlayer/VideoPlayer.tsx @@ -22,10 +22,12 @@ import Player from "video.js/dist/types/player"; import { Subtitle, SubtitleManager, + SubtitleManagerProps, SubtitlePublishedData, } from "./SubtitleManager"; import { base64ToBlobUrl } from "../../utils/base64"; import convert from "srt-webvtt"; +import { TimelineActionsComponent } from "./TimelineActionsComponent"; export async function srtBase64ToVttBlobUrl( base64Srt: string @@ -51,6 +53,24 @@ export async function srtBase64ToVttBlobUrl( } 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'; + }; interface VideoPlayerProps { qortalVideoResource: QortalGetMetadata; videoRef: Ref; @@ -58,8 +78,11 @@ interface VideoPlayerProps { poster?: string; autoPlay?: boolean; onEnded?: (e: React.SyntheticEvent) => void; + timelineActions?: TimelineAction[] } + + const videoStyles = { videoContainer: {}, video: {}, @@ -160,6 +183,7 @@ export const VideoPlayer = ({ poster, autoPlay, onEnded, + timelineActions }: VideoPlayerProps) => { const containerRef = useRef | null>(null); const [videoObjectFit] = useState("contain"); @@ -207,6 +231,7 @@ export const VideoPlayer = ({ percentLoaded, showControlsFullScreen, onSelectPlaybackRate, + seekTo } = useVideoPlayerController({ autoPlay, playerRef, @@ -324,7 +349,7 @@ export const VideoPlayer = ({ const videoStylesContainer = useMemo(() => { return { cursor: showControls ? "auto" : "none", - aspectRatio: "16 / 9", + // aspectRatio: "16 / 9", ...videoStyles?.videoContainer, }; }, [showControls]); @@ -584,9 +609,9 @@ export const VideoPlayer = ({ autoplay: true, controls: false, responsive: true, - fluid: true, + // fluid: true, poster: startPlay ? "" : poster, - aspectRatio: "16:9", + // aspectRatio: "16:9", sources: [ { src: resourceUrl, @@ -758,7 +783,10 @@ export const VideoPlayer = ({ toggleMute={toggleMute} /> )} + {timelineActions && Array.isArray(timelineActions) && ( + + )} { +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"); @@ -303,6 +314,6 @@ const togglePlay = useCallback(async () => { isReady, resourceUrl, startPlay, - status, percentLoaded, showControlsFullScreen, onSelectPlaybackRate: updatePlaybackRate + status, percentLoaded, showControlsFullScreen, onSelectPlaybackRate: updatePlaybackRate, seekTo }; }; diff --git a/src/hooks/useIdentifiers.tsx b/src/hooks/useIdentifiers.tsx index f874023..d880918 100644 --- a/src/hooks/useIdentifiers.tsx +++ b/src/hooks/useIdentifiers.tsx @@ -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, diff --git a/src/index.ts b/src/index.ts index acd86de..7eff849 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ 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 { VideoPlayer } from './components/VideoPlayer/VideoPlayer'; export { useListReturn } from './hooks/useListData'; diff --git a/src/utils/encryption.ts b/src/utils/encryption.ts index 008ee04..3cc9b59 100644 --- a/src/utils/encryption.ts +++ b/src/utils/encryption.ts @@ -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 { + // 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 { + // 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);