added timeline actions

This commit is contained in:
PhilReact 2025-06-16 04:50:22 +03:00
parent 8295688b8a
commit 945f5b7d83
8 changed files with 185 additions and 17 deletions

View File

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

View File

@ -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<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}: 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
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} variant="body2" onClick={()=> handleClick(action)}>
{action.label}
</Typography>
</ButtonBase>
)
} )}
</>
)
}

View File

@ -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": {

View File

@ -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<HTMLVideoElement>;
@ -58,8 +78,11 @@ interface VideoPlayerProps {
poster?: string;
autoPlay?: boolean;
onEnded?: (e: React.SyntheticEvent<HTMLVideoElement, Event>) => void;
timelineActions?: TimelineAction[]
}
const videoStyles = {
videoContainer: {},
video: {},
@ -160,6 +183,7 @@ export const VideoPlayer = ({
poster,
autoPlay,
onEnded,
timelineActions
}: VideoPlayerProps) => {
const containerRef = useRef<RefObject<HTMLDivElement> | null>(null);
const [videoObjectFit] = useState<StretchVideoType>("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) && (
<TimelineActionsComponent seekTo={seekTo} containerRef={containerRef} progress={localProgress} timelineActions={timelineActions}/>
)}
<SubtitleManager
subtitleBtnRef={subtitleBtnRef}
close={closeSubtitleManager}

View File

@ -217,6 +217,17 @@ try {
}
}, []);
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");
@ -303,6 +314,6 @@ const togglePlay = useCallback(async () => {
isReady,
resourceUrl,
startPlay,
status, percentLoaded, showControlsFullScreen, onSelectPlaybackRate: updatePlaybackRate
status, percentLoaded, showControlsFullScreen, onSelectPlaybackRate: updatePlaybackRate, seekTo
};
};

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,

View File

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

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