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, showLoading,
showSuccess, showSuccess,
} from "../../utils/toast"; } from "../../utils/toast";
interface SubtitleManagerProps {
export interface SubtitleManagerProps {
qortalMetadata: QortalGetMetadata; qortalMetadata: QortalGetMetadata;
close: () => void; close: () => void;
open: boolean; open: boolean;
onSelect: (subtitle: SubtitlePublishedData) => void; onSelect: (subtitle: SubtitlePublishedData) => void;
subtitleBtnRef: any; subtitleBtnRef: any;
currentSubTrack: null | string; currentSubTrack: null | string
} }
export interface Subtitle { export interface Subtitle {
language: string | null; language: string | null;
@ -123,7 +125,7 @@ const SubtitleManagerComponent = ({
setIsLoading(true); setIsLoading(true);
const videoId = `${qortalMetadata?.service}-${qortalMetadata?.name}-${qortalMetadata?.identifier}`; const videoId = `${qortalMetadata?.service}-${qortalMetadata?.name}-${qortalMetadata?.identifier}`;
console.log("videoId", videoId); console.log("videoId", videoId);
const postIdSearch = await identifierOperations.buildSearchPrefix( const postIdSearch = await identifierOperations.buildLooseSearchPrefix(
ENTITY_SUBTITLE, ENTITY_SUBTITLE,
videoId videoId
); );
@ -179,16 +181,16 @@ const SubtitleManagerComponent = ({
try { try {
const videoId = `${qortalMetadata?.service}-${qortalMetadata?.name}-${qortalMetadata?.identifier}`; const videoId = `${qortalMetadata?.service}-${qortalMetadata?.name}-${qortalMetadata?.identifier}`;
const identifier = await identifierOperations.buildIdentifier(
ENTITY_SUBTITLE,
videoId
);
const name = auth?.name; const name = auth?.name;
console.log("identifier2", identifier);
if (!name) return; if (!name) return;
const resources: ResourceToPublish[] = []; const resources: ResourceToPublish[] = [];
const tempResources: { qortalMetadata: QortalMetadata; data: any }[] = []; const tempResources: { qortalMetadata: QortalMetadata; data: any }[] = [];
for (const sub of subtitles) { for (const sub of subtitles) {
const identifier = await identifierOperations.buildLooseIdentifier(
ENTITY_SUBTITLE,
videoId
);
const data = { const data = {
subtitleData: sub.base64, subtitleData: sub.base64,
language: sub.language, 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 }) => ({ export const VideoElement = styled("video")(({ theme }) => ({
position: 'absolute',
top: 0,
bottom: 0,
right: 0,
left: 0,
background: "rgb(33, 33, 33)", background: "rgb(33, 33, 33)",
"&:focus": { "&:focus": {

View File

@ -22,10 +22,12 @@ import Player from "video.js/dist/types/player";
import { import {
Subtitle, Subtitle,
SubtitleManager, SubtitleManager,
SubtitleManagerProps,
SubtitlePublishedData, SubtitlePublishedData,
} from "./SubtitleManager"; } from "./SubtitleManager";
import { base64ToBlobUrl } from "../../utils/base64"; import { base64ToBlobUrl } from "../../utils/base64";
import convert from "srt-webvtt"; import convert from "srt-webvtt";
import { TimelineActionsComponent } from "./TimelineActionsComponent";
export async function srtBase64ToVttBlobUrl( export async function srtBase64ToVttBlobUrl(
base64Srt: string base64Srt: string
@ -51,6 +53,24 @@ export async function srtBase64ToVttBlobUrl(
} }
type StretchVideoType = "contain" | "fill" | "cover" | "none" | "scale-down"; 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 { interface VideoPlayerProps {
qortalVideoResource: QortalGetMetadata; qortalVideoResource: QortalGetMetadata;
videoRef: Ref<HTMLVideoElement>; videoRef: Ref<HTMLVideoElement>;
@ -58,8 +78,11 @@ interface VideoPlayerProps {
poster?: string; poster?: string;
autoPlay?: boolean; autoPlay?: boolean;
onEnded?: (e: React.SyntheticEvent<HTMLVideoElement, Event>) => void; onEnded?: (e: React.SyntheticEvent<HTMLVideoElement, Event>) => void;
timelineActions?: TimelineAction[]
} }
const videoStyles = { const videoStyles = {
videoContainer: {}, videoContainer: {},
video: {}, video: {},
@ -160,6 +183,7 @@ export const VideoPlayer = ({
poster, poster,
autoPlay, autoPlay,
onEnded, onEnded,
timelineActions
}: VideoPlayerProps) => { }: VideoPlayerProps) => {
const containerRef = useRef<RefObject<HTMLDivElement> | null>(null); const containerRef = useRef<RefObject<HTMLDivElement> | null>(null);
const [videoObjectFit] = useState<StretchVideoType>("contain"); const [videoObjectFit] = useState<StretchVideoType>("contain");
@ -207,6 +231,7 @@ export const VideoPlayer = ({
percentLoaded, percentLoaded,
showControlsFullScreen, showControlsFullScreen,
onSelectPlaybackRate, onSelectPlaybackRate,
seekTo
} = useVideoPlayerController({ } = useVideoPlayerController({
autoPlay, autoPlay,
playerRef, playerRef,
@ -324,7 +349,7 @@ export const VideoPlayer = ({
const videoStylesContainer = useMemo(() => { const videoStylesContainer = useMemo(() => {
return { return {
cursor: showControls ? "auto" : "none", cursor: showControls ? "auto" : "none",
aspectRatio: "16 / 9", // aspectRatio: "16 / 9",
...videoStyles?.videoContainer, ...videoStyles?.videoContainer,
}; };
}, [showControls]); }, [showControls]);
@ -584,9 +609,9 @@ export const VideoPlayer = ({
autoplay: true, autoplay: true,
controls: false, controls: false,
responsive: true, responsive: true,
fluid: true, // fluid: true,
poster: startPlay ? "" : poster, poster: startPlay ? "" : poster,
aspectRatio: "16:9", // aspectRatio: "16:9",
sources: [ sources: [
{ {
src: resourceUrl, src: resourceUrl,
@ -758,7 +783,10 @@ export const VideoPlayer = ({
toggleMute={toggleMute} toggleMute={toggleMute}
/> />
)} )}
{timelineActions && Array.isArray(timelineActions) && (
<TimelineActionsComponent seekTo={seekTo} containerRef={containerRef} progress={localProgress} timelineActions={timelineActions}/>
)}
<SubtitleManager <SubtitleManager
subtitleBtnRef={subtitleBtnRef} subtitleBtnRef={subtitleBtnRef}
close={closeSubtitleManager} 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(() => { const toggleObjectFit = useCallback(() => {
setVideoObjectFit(videoObjectFit === "contain" ? "fill" : "contain"); setVideoObjectFit(videoObjectFit === "contain" ? "fill" : "contain");
@ -303,6 +314,6 @@ const togglePlay = useCallback(async () => {
isReady, isReady,
resourceUrl, resourceUrl,
startPlay, 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 React, { useCallback, useMemo } from "react";
import { import {
buildIdentifier, buildIdentifier,
buildLooseIdentifier,
buildLooseSearchPrefix,
buildSearchPrefix, buildSearchPrefix,
EnumCollisionStrength, EnumCollisionStrength,
hashWord, hashWord,
@ -60,6 +62,8 @@ export const useIdentifiers = (publicSalt: string, appName: string) => {
createSingleIdentifier, createSingleIdentifier,
hashQortalName, hashQortalName,
hashString, hashString,
buildLooseSearchPrefix,
buildLooseIdentifier
}), }),
[ [
buildIdentifierFunc, buildIdentifierFunc,

View File

@ -4,6 +4,7 @@ export { useResourceStatus } from './hooks/useResourceStatus';
export { Spacer } from './common/Spacer'; export { Spacer } from './common/Spacer';
export { useModal } from './hooks/useModal'; export { useModal } from './hooks/useModal';
export { AudioPlayerControls , OnTrackChangeMeta, AudioPlayerProps, AudioPlayerHandle} from './components/AudioPlayer/AudioPlayerControls'; export { AudioPlayerControls , OnTrackChangeMeta, AudioPlayerProps, AudioPlayerHandle} from './components/AudioPlayer/AudioPlayerControls';
export {TimelineAction} from './components/VideoPlayer/VideoPlayer'
export { useAudioPlayerHotkeys } from './components/AudioPlayer/useAudioPlayerHotkeys'; export { useAudioPlayerHotkeys } from './components/AudioPlayer/useAudioPlayerHotkeys';
export { VideoPlayer } from './components/VideoPlayer/VideoPlayer'; export { VideoPlayer } from './components/VideoPlayer/VideoPlayer';
export { useListReturn } from './hooks/useListData'; export { useListReturn } from './hooks/useListData';

View File

@ -129,6 +129,33 @@ export async function buildSearchPrefix(
: `${appHash}-${entityPrefix}-`; // ✅ Global search for entity type : `${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` // Function to generate IDs dynamically with `publicSalt`
export async function buildIdentifier( export async function buildIdentifier(
appName: string, appName: string,
@ -166,6 +193,33 @@ export async function buildIdentifier(
return `${appHash}-${entityPrefix}-${parentRef}-${entityUid}-${IDENTIFIER_BUILDER_VERSION}`; 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 = () => { export const createSymmetricKeyAndNonce = () => {
const messageKey = new Uint8Array(32); // 32 bytes for the symmetric key const messageKey = new Uint8Array(32); // 32 bytes for the symmetric key
crypto.getRandomValues(messageKey); crypto.getRandomValues(messageKey);