Files
memefast/resources/js/pages/admin/partials/VideoTimelineViewer.jsx
2025-05-28 12:59:01 +08:00

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;