mirror of
https://github.com/Qortal/qapp-core.git
synced 2025-06-19 03:11:20 +00:00
added timeline actions
This commit is contained in:
parent
8295688b8a
commit
945f5b7d83
@ -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,
|
||||||
|
72
src/components/VideoPlayer/TimelineActionsComponent.tsx
Normal file
72
src/components/VideoPlayer/TimelineActionsComponent.tsx
Normal 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; // Don’t 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>
|
||||||
|
)
|
||||||
|
} )}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
@ -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": {
|
||||||
|
@ -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}
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -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,
|
||||||
|
@ -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';
|
||||||
|
@ -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);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user