2025-06-25 22:55:25 +02:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
useTimelineStore,
|
|
|
|
|
type TimelineClip,
|
|
|
|
|
type TimelineTrack,
|
|
|
|
|
} from "@/stores/timeline-store";
|
2025-07-01 01:13:14 +02:00
|
|
|
import {
|
|
|
|
|
useMediaStore,
|
|
|
|
|
type MediaItem,
|
|
|
|
|
getMediaAspectRatio,
|
|
|
|
|
} from "@/stores/media-store";
|
2025-06-25 22:55:25 +02:00
|
|
|
import { usePlaybackStore } from "@/stores/playback-store";
|
2025-06-30 23:19:37 +02:00
|
|
|
import { useEditorStore } from "@/stores/editor-store";
|
2025-06-25 22:55:25 +02:00
|
|
|
import { VideoPlayer } from "@/components/ui/video-player";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
2025-07-01 00:58:21 +02:00
|
|
|
import {
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
DropdownMenuItem,
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
DropdownMenuSeparator,
|
|
|
|
|
} from "@/components/ui/dropdown-menu";
|
|
|
|
|
import { Play, Pause, Volume2, VolumeX, Plus, Square } from "lucide-react";
|
2025-06-25 22:55:25 +02:00
|
|
|
import { useState, useRef, useEffect } from "react";
|
2025-07-01 00:58:21 +02:00
|
|
|
import { cn } from "@/lib/utils";
|
2025-07-01 01:30:02 +02:00
|
|
|
import { formatTimeCode } from "@/lib/time";
|
2025-06-25 22:55:25 +02:00
|
|
|
|
|
|
|
|
interface ActiveClip {
|
|
|
|
|
clip: TimelineClip;
|
|
|
|
|
track: TimelineTrack;
|
|
|
|
|
mediaItem: MediaItem | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function PreviewPanel() {
|
|
|
|
|
const { tracks } = useTimelineStore();
|
|
|
|
|
const { mediaItems } = useMediaStore();
|
2025-06-25 23:43:10 +02:00
|
|
|
const { currentTime, muted, toggleMute, volume } = usePlaybackStore();
|
2025-06-30 23:19:37 +02:00
|
|
|
const { canvasSize, canvasPresets, setCanvasSize } = useEditorStore();
|
2025-06-25 22:55:25 +02:00
|
|
|
const previewRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const [previewDimensions, setPreviewDimensions] = useState({
|
|
|
|
|
width: 0,
|
|
|
|
|
height: 0,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Calculate optimal preview size that fits in container while maintaining aspect ratio
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const updatePreviewSize = () => {
|
|
|
|
|
if (!containerRef.current) return;
|
|
|
|
|
|
|
|
|
|
const container = containerRef.current.getBoundingClientRect();
|
2025-06-25 23:43:10 +02:00
|
|
|
const computedStyle = getComputedStyle(containerRef.current);
|
|
|
|
|
|
|
|
|
|
// Get padding values
|
|
|
|
|
const paddingTop = parseFloat(computedStyle.paddingTop);
|
|
|
|
|
const paddingBottom = parseFloat(computedStyle.paddingBottom);
|
|
|
|
|
const paddingLeft = parseFloat(computedStyle.paddingLeft);
|
|
|
|
|
const paddingRight = parseFloat(computedStyle.paddingRight);
|
|
|
|
|
|
|
|
|
|
// Get gap value (gap-4 = 1rem = 16px)
|
|
|
|
|
const gap = parseFloat(computedStyle.gap) || 16;
|
|
|
|
|
|
|
|
|
|
// Get toolbar height if it exists
|
|
|
|
|
const toolbar = containerRef.current.querySelector("[data-toolbar]");
|
|
|
|
|
const toolbarHeight = toolbar
|
|
|
|
|
? toolbar.getBoundingClientRect().height
|
|
|
|
|
: 0;
|
|
|
|
|
|
|
|
|
|
// Calculate available space after accounting for padding, gap, and toolbar
|
|
|
|
|
const availableWidth = container.width - paddingLeft - paddingRight;
|
|
|
|
|
const availableHeight =
|
|
|
|
|
container.height -
|
|
|
|
|
paddingTop -
|
|
|
|
|
paddingBottom -
|
|
|
|
|
toolbarHeight -
|
|
|
|
|
(toolbarHeight > 0 ? gap : 0);
|
|
|
|
|
|
2025-06-25 22:55:25 +02:00
|
|
|
const targetRatio = canvasSize.width / canvasSize.height;
|
2025-06-25 23:43:10 +02:00
|
|
|
const containerRatio = availableWidth / availableHeight;
|
2025-06-25 22:55:25 +02:00
|
|
|
|
|
|
|
|
let width, height;
|
|
|
|
|
|
|
|
|
|
if (containerRatio > targetRatio) {
|
|
|
|
|
// Container is wider - constrain by height
|
2025-06-25 23:43:10 +02:00
|
|
|
height = availableHeight;
|
2025-06-25 22:55:25 +02:00
|
|
|
width = height * targetRatio;
|
|
|
|
|
} else {
|
|
|
|
|
// Container is taller - constrain by width
|
2025-06-25 23:43:10 +02:00
|
|
|
width = availableWidth;
|
2025-06-25 22:55:25 +02:00
|
|
|
height = width / targetRatio;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setPreviewDimensions({ width, height });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
updatePreviewSize();
|
|
|
|
|
|
|
|
|
|
const resizeObserver = new ResizeObserver(updatePreviewSize);
|
|
|
|
|
if (containerRef.current) {
|
|
|
|
|
resizeObserver.observe(containerRef.current);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return () => resizeObserver.disconnect();
|
|
|
|
|
}, [canvasSize.width, canvasSize.height]);
|
|
|
|
|
|
|
|
|
|
// Get active clips at current time
|
|
|
|
|
const getActiveClips = (): ActiveClip[] => {
|
|
|
|
|
const activeClips: ActiveClip[] = [];
|
|
|
|
|
|
|
|
|
|
tracks.forEach((track) => {
|
|
|
|
|
track.clips.forEach((clip) => {
|
|
|
|
|
const clipStart = clip.startTime;
|
|
|
|
|
const clipEnd =
|
|
|
|
|
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
|
|
|
|
|
|
|
|
|
if (currentTime >= clipStart && currentTime < clipEnd) {
|
|
|
|
|
const mediaItem =
|
|
|
|
|
clip.mediaId === "test"
|
|
|
|
|
? null // Test clips don't have a real media item
|
|
|
|
|
: mediaItems.find((item) => item.id === clip.mediaId) || null;
|
|
|
|
|
|
|
|
|
|
activeClips.push({ clip, track, mediaItem });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return activeClips;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const activeClips = getActiveClips();
|
|
|
|
|
|
2025-06-30 23:23:18 +02:00
|
|
|
// Check if there are any clips in the timeline at all
|
|
|
|
|
const hasAnyClips = tracks.some((track) => track.clips.length > 0);
|
|
|
|
|
|
2025-06-25 22:55:25 +02:00
|
|
|
// Render a clip
|
|
|
|
|
const renderClip = (clipData: ActiveClip, index: number) => {
|
|
|
|
|
const { clip, mediaItem } = clipData;
|
|
|
|
|
|
|
|
|
|
// Test clips
|
|
|
|
|
if (!mediaItem || clip.mediaId === "test") {
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={clip.id}
|
|
|
|
|
className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
|
|
|
|
|
>
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<div className="text-2xl mb-2">🎬</div>
|
|
|
|
|
<p className="text-xs text-white">{clip.name}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Video clips
|
|
|
|
|
if (mediaItem.type === "video") {
|
|
|
|
|
return (
|
|
|
|
|
<div key={clip.id} className="absolute inset-0">
|
|
|
|
|
<VideoPlayer
|
|
|
|
|
src={mediaItem.url}
|
|
|
|
|
poster={mediaItem.thumbnailUrl}
|
|
|
|
|
clipStartTime={clip.startTime}
|
|
|
|
|
trimStart={clip.trimStart}
|
|
|
|
|
trimEnd={clip.trimEnd}
|
|
|
|
|
clipDuration={clip.duration}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Image clips
|
|
|
|
|
if (mediaItem.type === "image") {
|
|
|
|
|
return (
|
|
|
|
|
<div key={clip.id} className="absolute inset-0">
|
|
|
|
|
<img
|
|
|
|
|
src={mediaItem.url}
|
|
|
|
|
alt={mediaItem.name}
|
|
|
|
|
className="w-full h-full object-cover"
|
|
|
|
|
draggable={false}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Audio clips (visual representation)
|
|
|
|
|
if (mediaItem.type === "audio") {
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={clip.id}
|
|
|
|
|
className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center"
|
|
|
|
|
>
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<div className="text-2xl mb-2">🎵</div>
|
|
|
|
|
<p className="text-xs text-white">{mediaItem.name}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="h-full w-full flex flex-col min-h-0 min-w-0">
|
|
|
|
|
<div
|
|
|
|
|
ref={containerRef}
|
2025-06-25 23:43:10 +02:00
|
|
|
className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0 gap-4"
|
2025-06-25 22:55:25 +02:00
|
|
|
>
|
2025-07-01 00:58:21 +02:00
|
|
|
{hasAnyClips ? (
|
2025-06-30 23:23:18 +02:00
|
|
|
<div
|
|
|
|
|
ref={previewRef}
|
|
|
|
|
className="relative overflow-hidden rounded-sm bg-black border"
|
|
|
|
|
style={{
|
|
|
|
|
width: previewDimensions.width,
|
|
|
|
|
height: previewDimensions.height,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{activeClips.length === 0 ? (
|
|
|
|
|
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
|
|
|
|
|
No clips at current time
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
activeClips.map((clipData, index) => renderClip(clipData, index))
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-07-01 00:58:21 +02:00
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
{/* Empty div so toolbar stays at the bottom */}
|
|
|
|
|
<div className="w-full h-full"></div>
|
|
|
|
|
</>
|
2025-06-30 23:23:18 +02:00
|
|
|
)}
|
2025-06-25 23:43:10 +02:00
|
|
|
|
2025-07-01 00:58:21 +02:00
|
|
|
<PreviewToolbar hasAnyClips={hasAnyClips} />
|
2025-06-25 22:55:25 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-06-25 23:43:10 +02:00
|
|
|
|
2025-07-01 00:58:21 +02:00
|
|
|
function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) {
|
2025-07-01 01:30:02 +02:00
|
|
|
const { isPlaying, toggle, currentTime } = usePlaybackStore();
|
2025-07-01 00:58:21 +02:00
|
|
|
const {
|
|
|
|
|
canvasSize,
|
|
|
|
|
canvasPresets,
|
|
|
|
|
setCanvasSize,
|
|
|
|
|
setCanvasSizeFromAspectRatio,
|
|
|
|
|
} = useEditorStore();
|
|
|
|
|
const { mediaItems } = useMediaStore();
|
2025-07-01 01:30:02 +02:00
|
|
|
const { tracks, getTotalDuration } = useTimelineStore();
|
2025-07-01 00:58:21 +02:00
|
|
|
|
|
|
|
|
// Find the current preset based on canvas size
|
|
|
|
|
const currentPreset = canvasPresets.find(
|
|
|
|
|
(preset) =>
|
|
|
|
|
preset.width === canvasSize.width && preset.height === canvasSize.height
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handlePresetSelect = (preset: { width: number; height: number }) => {
|
|
|
|
|
setCanvasSize({ width: preset.width, height: preset.height });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Get the first video/image media item to determine original aspect ratio
|
|
|
|
|
const getOriginalAspectRatio = () => {
|
|
|
|
|
// Find first video or image in timeline
|
|
|
|
|
for (const track of tracks) {
|
|
|
|
|
for (const clip of track.clips) {
|
|
|
|
|
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
|
|
|
|
|
if (
|
|
|
|
|
mediaItem &&
|
|
|
|
|
(mediaItem.type === "video" || mediaItem.type === "image")
|
|
|
|
|
) {
|
2025-07-01 01:13:14 +02:00
|
|
|
return getMediaAspectRatio(mediaItem);
|
2025-07-01 00:58:21 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return 16 / 9; // Default aspect ratio
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleOriginalSelect = () => {
|
|
|
|
|
const aspectRatio = getOriginalAspectRatio();
|
|
|
|
|
setCanvasSizeFromAspectRatio(aspectRatio);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Check if current size is "Original" (not matching any preset)
|
|
|
|
|
const isOriginal = !currentPreset;
|
2025-06-25 23:43:10 +02:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
data-toolbar
|
2025-07-01 00:58:21 +02:00
|
|
|
className="flex items-end justify-between gap-2 p-1 pt-2 bg-background-500 w-full"
|
2025-06-25 23:43:10 +02:00
|
|
|
>
|
2025-07-01 00:58:21 +02:00
|
|
|
<div>
|
|
|
|
|
<p
|
|
|
|
|
className={cn(
|
2025-07-01 01:30:02 +02:00
|
|
|
"text-xs text-muted-foreground tabular-nums",
|
2025-07-01 00:58:21 +02:00
|
|
|
!hasAnyClips && "opacity-50"
|
|
|
|
|
)}
|
|
|
|
|
>
|
2025-07-01 01:30:02 +02:00
|
|
|
{formatTimeCode(currentTime, "HH:MM:SS:CS")}/
|
|
|
|
|
{formatTimeCode(getTotalDuration(), "HH:MM:SS:CS")}
|
2025-07-01 00:58:21 +02:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
variant="text"
|
|
|
|
|
size="icon"
|
|
|
|
|
onClick={toggle}
|
|
|
|
|
disabled={!hasAnyClips}
|
|
|
|
|
>
|
2025-06-25 23:43:10 +02:00
|
|
|
{isPlaying ? (
|
|
|
|
|
<Pause className="h-3 w-3" />
|
|
|
|
|
) : (
|
|
|
|
|
<Play className="h-3 w-3" />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
2025-07-01 00:58:21 +02:00
|
|
|
<div>
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
2025-07-03 17:26:49 +02:00
|
|
|
className="!bg-background text-foreground/85 text-xs h-auto rounded-none border border-muted-foreground px-0.5 py-0 font-light"
|
2025-07-01 00:58:21 +02:00
|
|
|
disabled={!hasAnyClips}
|
|
|
|
|
>
|
|
|
|
|
{currentPreset?.name || "Ratio"}
|
|
|
|
|
</Button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="end">
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
onClick={handleOriginalSelect}
|
|
|
|
|
className={cn("text-xs", isOriginal && "font-semibold")}
|
|
|
|
|
>
|
|
|
|
|
Original
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
{canvasPresets.map((preset) => (
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
key={preset.name}
|
|
|
|
|
onClick={() => handlePresetSelect(preset)}
|
|
|
|
|
className={cn(
|
|
|
|
|
"text-xs",
|
|
|
|
|
currentPreset?.name === preset.name && "font-semibold"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{preset.name}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
))}
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
</div>
|
2025-06-25 23:43:10 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|