483 lines
25 KiB
JavaScript
483 lines
25 KiB
JavaScript
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import useAdminVideoTimelineStore from '@/stores/AdminVideoTimelineStore';
|
|
import { ZoomInIcon, ZoomOutIcon } from 'lucide-react';
|
|
import React, { useEffect, useRef, useState } from 'react';
|
|
|
|
const VideoTimelineViewer = () => {
|
|
const [videoUuid, setVideoUuid] = useState('c4fd0601-cac7-483b-907e-0ae4c108d3b1');
|
|
const [zoomLevel, setZoomLevel] = useState(100); // Default zoom level (100%)
|
|
const { getVideoTimelines, videoTimelines, isLoadingVideoTimelines } = useAdminVideoTimelineStore();
|
|
const timelineContainerRef = useRef(null);
|
|
|
|
// Define a dark theme for tooltips
|
|
const tooltipStyle = {
|
|
content: 'p-2 bg-gray-800 border border-gray-700 rounded shadow-md z-50 text-white',
|
|
text: 'text-white',
|
|
highlight: 'text-white font-semibold',
|
|
};
|
|
|
|
// Define an array of 20 distinct Tailwind background colors
|
|
const colorPresets = [
|
|
'bg-blue-500', // Blue
|
|
'bg-green-500', // Green
|
|
'bg-purple-500', // Purple
|
|
'bg-red-500', // Red
|
|
'bg-yellow-500', // Yellow
|
|
'bg-pink-500', // Pink
|
|
'bg-indigo-500', // Indigo
|
|
'bg-teal-500', // Teal
|
|
'bg-orange-500', // Orange
|
|
'bg-cyan-500', // Cyan
|
|
'bg-lime-500', // Lime
|
|
'bg-emerald-500', // Emerald
|
|
'bg-sky-500', // Sky blue
|
|
'bg-violet-500', // Violet
|
|
'bg-fuchsia-500', // Fuchsia
|
|
'bg-rose-500', // Rose
|
|
'bg-amber-500', // Amber
|
|
'bg-blue-600', // Darker blue
|
|
'bg-green-600', // Darker green
|
|
'bg-purple-600', // Darker purple
|
|
];
|
|
|
|
// Use a ref to persist the color map between renders
|
|
const colorMapRef = useRef(new Map());
|
|
|
|
// Get color based on element type
|
|
const getElementColor = (type) => {
|
|
// If we've already assigned a color to this type, use it
|
|
if (colorMapRef.current.has(type)) {
|
|
return colorMapRef.current.get(type);
|
|
}
|
|
|
|
// Otherwise, assign the next color in the sequence
|
|
const currentCount = colorMapRef.current.size;
|
|
const colorIndex = currentCount % colorPresets.length;
|
|
const newColor = colorPresets[colorIndex];
|
|
|
|
// Store the mapping for future use
|
|
colorMapRef.current.set(type, newColor);
|
|
|
|
return newColor;
|
|
};
|
|
|
|
const handleSubmit = (e) => {
|
|
e.preventDefault();
|
|
if (videoUuid.trim()) {
|
|
getVideoTimelines(videoUuid);
|
|
}
|
|
};
|
|
|
|
const handleZoomIn = () => {
|
|
// Use different increments based on current zoom level
|
|
if (zoomLevel < 50) {
|
|
setZoomLevel((prev) => Math.min(prev + 5, 300)); // Smaller steps at low zoom
|
|
} else {
|
|
setZoomLevel((prev) => Math.min(prev + 25, 300)); // Larger steps at normal zoom
|
|
}
|
|
};
|
|
|
|
const handleZoomOut = () => {
|
|
// Use different decrements based on current zoom level
|
|
if (zoomLevel <= 50) {
|
|
setZoomLevel((prev) => Math.max(prev - 5, 5)); // Smaller steps at low zoom, min 5%
|
|
} else {
|
|
setZoomLevel((prev) => Math.max(prev - 25, 5)); // Larger steps at normal zoom, min 5%
|
|
}
|
|
};
|
|
|
|
// Calculate the maximum duration for scaling
|
|
const getMaxDuration = () => {
|
|
if (!videoTimelines.length) return 7; // Default value
|
|
|
|
let maxEnd = 0;
|
|
videoTimelines.forEach((element) => {
|
|
const elementEnd = element.time + element.duration;
|
|
if (elementEnd > maxEnd) {
|
|
maxEnd = elementEnd;
|
|
}
|
|
});
|
|
|
|
return maxEnd;
|
|
};
|
|
|
|
// Get unique tracks for vertical positioning
|
|
const getTracks = () => {
|
|
if (!videoTimelines.length) return [];
|
|
|
|
const tracks = [...new Set(videoTimelines.map((element) => element.track))];
|
|
return tracks.sort((a, b) => b - a); // Sort in descending order
|
|
};
|
|
|
|
// Get track information for tooltips
|
|
const getTrackInfo = (trackNum) => {
|
|
const trackElements = videoTimelines.filter((element) => element.track === trackNum);
|
|
|
|
if (trackElements.length === 0) {
|
|
return { count: 0, duration: 0, startTime: 0, endTime: 0 };
|
|
}
|
|
|
|
let totalDuration = 0;
|
|
let minStartTime = Infinity;
|
|
let maxEndTime = 0;
|
|
|
|
trackElements.forEach((element) => {
|
|
totalDuration += element.duration;
|
|
if (element.time < minStartTime) minStartTime = element.time;
|
|
const endTime = element.time + element.duration;
|
|
if (endTime > maxEndTime) maxEndTime = endTime;
|
|
});
|
|
|
|
return {
|
|
count: trackElements.length,
|
|
duration: totalDuration,
|
|
startTime: minStartTime,
|
|
endTime: maxEndTime,
|
|
elements: trackElements,
|
|
};
|
|
};
|
|
|
|
// Keep track of previous zoom level to calculate proper scrolling
|
|
const prevZoomRef = useRef(zoomLevel);
|
|
|
|
// Scroll timeline to position when zoom changes
|
|
useEffect(() => {
|
|
if (timelineContainerRef.current && videoTimelines.length > 0) {
|
|
const container = timelineContainerRef.current;
|
|
const prevZoom = prevZoomRef.current;
|
|
|
|
// Calculate the center point of the current view
|
|
const viewportWidth = container.clientWidth;
|
|
const scrollLeft = container.scrollLeft;
|
|
const centerPoint = scrollLeft + viewportWidth / 2;
|
|
|
|
// Calculate what percentage of the timeline this center point represents
|
|
const timelineWidth = container.firstChild.offsetWidth;
|
|
const centerPercentage = centerPoint / ((timelineWidth * prevZoom) / zoomLevel);
|
|
|
|
// Calculate the new scroll position to keep that same percentage in the center
|
|
const newTimelineWidth = timelineWidth;
|
|
const newCenterPoint = centerPercentage * newTimelineWidth;
|
|
const newScrollPosition = newCenterPoint - viewportWidth / 2;
|
|
|
|
// Update the scroll position
|
|
requestAnimationFrame(() => {
|
|
container.scrollLeft = Math.max(0, newScrollPosition);
|
|
});
|
|
|
|
// Save current zoom for next comparison
|
|
prevZoomRef.current = zoomLevel;
|
|
}
|
|
}, [zoomLevel, videoTimelines.length]);
|
|
|
|
// Get label based on external reference
|
|
const getElementLabel = (element) => {
|
|
if (element.external_reference) {
|
|
return element.external_reference.replace(/_/g, ' ');
|
|
}
|
|
return element.type;
|
|
};
|
|
|
|
// Calculate time scale factor based on zoom level
|
|
const getTimeScaleFactor = () => {
|
|
// At 100%, scale factor is 1
|
|
// Below 100%, scale shrinks proportionally
|
|
// Above 100%, scale grows proportionally
|
|
return zoomLevel / 100;
|
|
};
|
|
|
|
const maxDuration = getMaxDuration();
|
|
const tracks = getTracks();
|
|
const trackHeight = 40; // Height of each track row
|
|
const timelineHeight = tracks.length * trackHeight;
|
|
|
|
return (
|
|
<Card className="mx-auto w-full shadow-lg">
|
|
<CardHeader>
|
|
<CardTitle className="text-xl font-semibold">Video UUID</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleSubmit} className="flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-2">
|
|
<Input
|
|
type="text"
|
|
placeholder="Enter video UUID..."
|
|
value={videoUuid}
|
|
onChange={(e) => setVideoUuid(e.target.value)}
|
|
className="flex-1"
|
|
/>
|
|
<Button type="submit" disabled={isLoadingVideoTimelines} className="w-full sm:w-auto">
|
|
{isLoadingVideoTimelines ? 'Loading...' : 'Submit'}
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
|
|
<Separator className="my-2" />
|
|
|
|
<CardContent className="pb-6">
|
|
<div className="mt-4">
|
|
<h3 className="mb-2 font-medium">Timeline Preview</h3>
|
|
|
|
{/* Zoom controls */}
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<div className="flex items-center space-x-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-8 w-8 p-0"
|
|
onClick={handleZoomOut}
|
|
disabled={zoomLevel <= 5}
|
|
title="Zoom out"
|
|
>
|
|
<ZoomOutIcon className="h-4 w-4" />
|
|
</Button>
|
|
<span className="text-xs">{zoomLevel}%</span>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-8 w-8 p-0"
|
|
onClick={handleZoomIn}
|
|
disabled={zoomLevel >= 300}
|
|
title="Zoom in"
|
|
>
|
|
<ZoomInIcon className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
<div className="text-xs text-gray-500">
|
|
<span>Adjust zoom to change timeline scale</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Timeline container with horizontal scroll for small screens */}
|
|
<div className="overflow-x-auto" ref={timelineContainerRef}>
|
|
<div
|
|
className="min-w-[640px]"
|
|
style={
|
|
{
|
|
// We don't scale the container width with zoom anymore
|
|
// Instead we scale the individual elements inside
|
|
}
|
|
}
|
|
>
|
|
{/* Timeline header - time markers with tick marks */}
|
|
<div className="flex h-8 border-b border-gray-200 text-xs text-gray-500">
|
|
<div className="w-16 flex-shrink-0 pr-2 font-medium sm:w-20">Track</div>
|
|
<div className="relative flex-1">
|
|
{/* Vertical guideline marks at each second */}
|
|
{Array.from({ length: Math.ceil(maxDuration) + 1 }).map((_, i) => {
|
|
const scaleFactor = zoomLevel / 100;
|
|
const baseWidth = 100; // Base width in pixels at 100% zoom
|
|
const position = i * baseWidth * scaleFactor;
|
|
|
|
return (
|
|
<React.Fragment key={i}>
|
|
<div
|
|
className="absolute h-full border-l border-gray-200"
|
|
style={{
|
|
left: `${position}px`,
|
|
height: `${timelineHeight + 20}px`,
|
|
top: '16px',
|
|
zIndex: 0,
|
|
}}
|
|
/>
|
|
<div
|
|
className="absolute -mx-1 text-start"
|
|
style={{
|
|
left: `${position}px`,
|
|
width: `${baseWidth * scaleFactor}px`,
|
|
}}
|
|
>
|
|
{i}s
|
|
</div>
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="relative"
|
|
style={{
|
|
height: `${timelineHeight}px`,
|
|
paddingLeft: '0px', // Remove any padding that might affect alignment
|
|
}}
|
|
>
|
|
{/* Track rows */}
|
|
{tracks.map((trackNum, index) => {
|
|
const trackInfo = getTrackInfo(trackNum);
|
|
|
|
return (
|
|
<div
|
|
key={trackNum}
|
|
className="absolute flex w-full items-center border-b border-gray-100"
|
|
style={{
|
|
top: `${index * trackHeight}px`,
|
|
height: `${trackHeight}px`,
|
|
zIndex: 1, // Ensure track labels are above elements
|
|
}}
|
|
>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button className="w-16 flex-shrink-0 cursor-help bg-white pr-2 text-left text-xs font-medium transition-colors hover:bg-gray-50 sm:w-20">
|
|
Track {trackNum}
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent
|
|
side="right"
|
|
sideOffset={5}
|
|
className="z-50 rounded border border-gray-700 bg-gray-800 p-3 text-white shadow-md"
|
|
>
|
|
<div className="space-y-1 text-sm">
|
|
<p className="font-semibold">Track {trackNum}</p>
|
|
<p>Elements: {trackInfo.count}</p>
|
|
<p>Total duration: {trackInfo.duration.toFixed(2)}s</p>
|
|
{trackInfo.count > 0 && (
|
|
<>
|
|
<p>Start time: {trackInfo.startTime.toFixed(2)}s</p>
|
|
<p>End time: {trackInfo.endTime.toFixed(2)}s</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Timeline elements */}
|
|
{videoTimelines.map((element) => {
|
|
const trackIndex = tracks.indexOf(element.track);
|
|
const scaleFactor = zoomLevel / 100;
|
|
const baseWidth = 100; // Base width of 1 second at 100% zoom
|
|
|
|
// Position from the exact timeline start (after the track label)
|
|
const trackLabelWidth = 64; // 16rem = 64px on small screens, 80px on larger
|
|
const exactStartPosition = element.time * baseWidth * scaleFactor;
|
|
const startPosition = trackLabelWidth + exactStartPosition;
|
|
const elementWidth = element.duration * baseWidth * scaleFactor;
|
|
|
|
return (
|
|
<TooltipProvider key={element.id}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div
|
|
className={`absolute ml-4 cursor-pointer rounded px-1 text-xs text-white ${getElementColor(element.external_reference)} flex items-center hover:opacity-90`}
|
|
style={{
|
|
top: `${trackIndex * trackHeight + 5}px`,
|
|
left: `${startPosition}px`,
|
|
width: `${Math.max(2, elementWidth - 1)}px`, // Subtract 1px to create spacing, min 2px
|
|
height: `${trackHeight - 10}px`,
|
|
overflow: 'hidden',
|
|
whiteSpace: 'nowrap',
|
|
textOverflow: 'ellipsis',
|
|
minWidth: zoomLevel < 20 ? '2px' : zoomLevel < 50 ? '10px' : '30px', // Adaptive minimum width
|
|
marginRight: '1px', // Add 1px space between elements
|
|
zIndex: 2, // Ensure elements are above track rows
|
|
}}
|
|
>
|
|
{getElementLabel(element)}
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent
|
|
side="top"
|
|
sideOffset={5}
|
|
className="z-50 rounded border border-gray-700 bg-gray-800 p-2 text-white shadow-md"
|
|
>
|
|
<div className="space-y-1 text-sm">
|
|
<p className="font-semibold">{getElementLabel(element)}</p>
|
|
<p>Type: {element.type}</p>
|
|
<p>Track: {element.track}</p>
|
|
<p>Start: {element.time.toFixed(2)}s</p>
|
|
<p>Duration: {element.duration.toFixed(2)}s</p>
|
|
<p>End: {(element.time + element.duration).toFixed(2)}s</p>
|
|
{element.id && <p>ID: {element.id}</p>}
|
|
</div>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Element details section */}
|
|
<div className="mt-4">
|
|
<h3 className="mb-2 font-medium">Element Details</h3>
|
|
<div className="overflow-auto rounded border border-gray-200" style={{ maxHeight: '40vh' }}>
|
|
<table className="min-w-full divide-y divide-gray-200">
|
|
<thead className="sticky top-0 bg-gray-50">
|
|
<tr>
|
|
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 sm:px-4">ID</th>
|
|
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 sm:px-4">Type</th>
|
|
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 sm:px-4">Reference</th>
|
|
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 sm:px-4">Time</th>
|
|
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 sm:px-4">Duration</th>
|
|
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 sm:px-4">Track</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200 bg-white">
|
|
{videoTimelines.map((element) => (
|
|
<tr key={element.id} className="hover:bg-gray-50">
|
|
<td className="px-2 py-2 text-xs whitespace-nowrap sm:px-4">{element.id}</td>
|
|
<td className="px-2 py-2 text-xs whitespace-nowrap sm:px-4">{element.type}</td>
|
|
<td className="px-2 py-2 text-xs whitespace-nowrap sm:px-4">
|
|
<span
|
|
className={`inline-block h-3 w-3 rounded-full ${getElementColor(element.external_reference)}`}
|
|
></span>{' '}
|
|
{element.external_reference}
|
|
</td>
|
|
<td className="px-2 py-2 text-xs whitespace-nowrap sm:px-4">{element.time}s</td>
|
|
<td className="px-2 py-2 text-xs whitespace-nowrap sm:px-4">{element.duration}s</td>
|
|
<td className="px-2 py-2 text-xs whitespace-nowrap sm:px-4">{element.track}</td>
|
|
</tr>
|
|
))}
|
|
{videoTimelines.length === 0 && (
|
|
<tr>
|
|
<td colSpan="6" className="py-4 text-center text-sm text-gray-500">
|
|
No timeline data available
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
|
|
<CardFooter className="flex flex-col gap-2 py-2 sm:flex-row sm:justify-between">
|
|
<div className="flex flex-wrap gap-2 text-xs text-gray-500">
|
|
{/* Dynamically generate color legend based on types that appear in the data */}
|
|
{Array.from(colorMapRef.current.entries()).map(([type, color]) => (
|
|
<div key={type} className="flex items-center">
|
|
<span className={`mr-1 inline-block h-3 w-3 rounded-full ${color}`}></span>
|
|
{type}
|
|
</div>
|
|
))}
|
|
{colorMapRef.current.size === 0 && (
|
|
<>
|
|
<div className="flex items-center">
|
|
<span className="mr-1 inline-block h-3 w-3 rounded-full bg-blue-500"></span>
|
|
No elements
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<p className="text-xs text-gray-400">Total elements: {videoTimelines.length}</p>
|
|
<div className="text-xs text-gray-400">|</div>
|
|
<p className="text-xs text-gray-400">Zoom: {zoomLevel}%</p>
|
|
</div>
|
|
</CardFooter>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export default VideoTimelineViewer;
|