feat: so much stuff

This commit is contained in:
Maze Winther
2025-06-22 19:28:03 +02:00
parent e22aa6620c
commit 6ee16f9df8
9 changed files with 1229 additions and 83 deletions

View File

@ -19,17 +19,164 @@ import {
} from "../ui/tooltip";
import { DragOverlay } from "../ui/drag-overlay";
import { useTimelineStore, type TimelineTrack } from "@/stores/timeline-store";
import { useDragDrop } from "@/hooks/use-drag-drop";
import { useMediaStore } from "@/stores/media-store";
import { processMediaFiles } from "@/lib/media-processing";
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
import { toast } from "sonner";
import { useState, useRef } from "react";
export function Timeline() {
const { tracks, addTrack } = useTimelineStore();
const { tracks, addTrack, addClipToTrack } = useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore();
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const dragCounterRef = useRef(0);
const { isDragOver, dragProps } = useDragDrop({
onDrop: (files) => {
// TODO: Handle file drop functionality for timeline
console.log("Files dropped on timeline:", files);
},
});
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
// Don't show overlay for timeline clips or other internal drags
if (e.dataTransfer.types.includes("application/x-timeline-clip")) {
return;
}
dragCounterRef.current += 1;
if (!isDragOver) {
setIsDragOver(true);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
// Don't update state for timeline clips
if (e.dataTransfer.types.includes("application/x-timeline-clip")) {
return;
}
dragCounterRef.current -= 1;
if (dragCounterRef.current === 0) {
setIsDragOver(false);
}
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
dragCounterRef.current = 0;
// Check if this is a timeline clip drop - now we'll handle it!
const timelineClipData = e.dataTransfer.getData(
"application/x-timeline-clip"
);
if (timelineClipData) {
// Timeline clips dropped on the main timeline area (not on a specific track)
// For now, we'll just ignore these - clips should be dropped on specific tracks
return;
}
// Check if this is an internal media item drop
const mediaItemData = e.dataTransfer.getData("application/x-media-item");
if (mediaItemData) {
try {
const { id, type, name } = JSON.parse(mediaItemData);
// Find the full media item from the store
const mediaItem = mediaItems.find((item) => item.id === id);
if (!mediaItem) {
toast.error("Media item not found");
return;
}
// Determine track type based on media type
let trackType: "video" | "audio" | "effects";
if (type === "video") {
trackType = "video";
} else if (type === "audio") {
trackType = "audio";
} else {
// For images, we'll put them on video tracks
trackType = "video";
}
// Create a new track and get its ID
const newTrackId = addTrack(trackType);
// Add the clip to the new track
addClipToTrack(newTrackId, {
mediaId: mediaItem.id,
name: mediaItem.name,
duration: mediaItem.duration || 5, // Default 5 seconds for images
});
toast.success(`Added ${name} to ${trackType} track`);
} catch (error) {
console.error("Error parsing media item data:", error);
toast.error("Failed to add media to timeline");
}
} else if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
// Handle external file drops
setIsProcessing(true);
try {
const processedItems = await processMediaFiles(e.dataTransfer.files);
for (const processedItem of processedItems) {
// Add to media store first
addMediaItem(processedItem);
// The media item now has an ID, let's get it from the latest state
// Since addMediaItem is synchronous, we can get the latest item
const currentMediaItems = useMediaStore.getState().mediaItems;
const addedItem = currentMediaItems.find(
(item) =>
item.name === processedItem.name && item.url === processedItem.url
);
if (addedItem) {
// Determine track type based on media type
let trackType: "video" | "audio" | "effects";
if (processedItem.type === "video") {
trackType = "video";
} else if (processedItem.type === "audio") {
trackType = "audio";
} else {
// For images, we'll put them on video tracks
trackType = "video";
}
// Create a new track and get its ID
const newTrackId = addTrack(trackType);
// Add the clip to the new track
addClipToTrack(newTrackId, {
mediaId: addedItem.id,
name: addedItem.name,
duration: addedItem.duration || 5, // Default 5 seconds for images
});
toast.success(`Added ${processedItem.name} to timeline`);
}
}
} catch (error) {
console.error("Error processing external files:", error);
toast.error("Failed to process dropped files");
} finally {
setIsProcessing(false);
}
}
};
const dragProps = {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
};
return (
<div
@ -40,8 +187,12 @@ export function Timeline() {
>
<DragOverlay
isVisible={isDragOver}
title="Drop files here"
description="Add media to timeline tracks"
title={isProcessing ? "Processing files..." : "Drop media here"}
description={
isProcessing
? "Please wait while files are being processed"
: "Add media to timeline tracks"
}
/>
{/* Toolbar */}
@ -154,6 +305,149 @@ export function Timeline() {
}
function TimelineTrackComponent({ track }: { track: TimelineTrack }) {
const { mediaItems } = useMediaStore();
const { moveClipToTrack, reorderClipInTrack } = useTimelineStore();
const [isDropping, setIsDropping] = useState(false);
const handleClipDragStart = (e: React.DragEvent, clip: any) => {
// Mark this as an timeline clip drag to differentiate from media items
const dragData = {
clipId: clip.id,
trackId: track.id,
name: clip.name,
};
e.dataTransfer.setData(
"application/x-timeline-clip",
JSON.stringify(dragData)
);
e.dataTransfer.effectAllowed = "move";
// Use the entire clip container as the drag image instead of just the content
const target = e.currentTarget as HTMLElement;
e.dataTransfer.setDragImage(
target,
target.offsetWidth / 2,
target.offsetHeight / 2
);
};
const handleTrackDragOver = (e: React.DragEvent) => {
e.preventDefault();
// Only handle timeline clip drags
if (!e.dataTransfer.types.includes("application/x-timeline-clip")) {
return;
}
e.dataTransfer.dropEffect = "move";
};
const handleTrackDragEnter = (e: React.DragEvent) => {
e.preventDefault();
// Only handle timeline clip drags
if (!e.dataTransfer.types.includes("application/x-timeline-clip")) {
return;
}
setIsDropping(true);
};
const handleTrackDragLeave = (e: React.DragEvent) => {
e.preventDefault();
// Only handle timeline clip drags
if (!e.dataTransfer.types.includes("application/x-timeline-clip")) {
return;
}
// Check if we're actually leaving the track area
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
const isActuallyLeaving =
x < rect.left || x > rect.right || y < rect.top || y > rect.bottom;
if (isActuallyLeaving) {
setIsDropping(false);
}
};
const handleTrackDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDropping(false);
// Only handle timeline clip drags
if (!e.dataTransfer.types.includes("application/x-timeline-clip")) {
return;
}
const timelineClipData = e.dataTransfer.getData(
"application/x-timeline-clip"
);
if (!timelineClipData) {
return;
}
try {
const parsedData = JSON.parse(timelineClipData);
const { clipId, trackId: fromTrackId } = parsedData;
// Calculate where to insert the clip based on mouse position
const trackContainer = e.currentTarget.querySelector(
".track-clips-container"
) as HTMLElement;
if (!trackContainer) {
return;
}
const rect = trackContainer.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
// Calculate insertion index based on position
let insertIndex = 0;
const clipElements = trackContainer.querySelectorAll(".timeline-clip");
for (let i = 0; i < clipElements.length; i++) {
const clipRect = clipElements[i].getBoundingClientRect();
const clipCenterX = clipRect.left + clipRect.width / 2 - rect.left;
if (mouseX > clipCenterX) {
insertIndex = i + 1;
} else {
break;
}
}
if (fromTrackId === track.id) {
// Moving within the same track - reorder
const currentIndex = track.clips.findIndex(
(clip) => clip.id === clipId
);
if (currentIndex !== -1 && currentIndex !== insertIndex) {
// Adjust index if we're moving to a position after the current one
const adjustedIndex =
insertIndex > currentIndex ? insertIndex - 1 : insertIndex;
reorderClipInTrack(track.id, clipId, adjustedIndex);
toast.success("Clip reordered");
}
} else {
// Moving between different tracks
moveClipToTrack(fromTrackId, track.id, clipId, insertIndex);
toast.success("Clip moved to different track");
}
} catch (error) {
console.error("Error moving clip:", error);
toast.error("Failed to move clip");
}
};
const getTrackColor = (type: string) => {
switch (type) {
case "video":
@ -167,26 +461,93 @@ function TimelineTrackComponent({ track }: { track: TimelineTrack }) {
}
};
const renderClipContent = (clip: any) => {
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
if (!mediaItem) {
return (
<span className="text-xs text-foreground/80 truncate">{clip.name}</span>
);
}
if (mediaItem.type === "image") {
return (
<div className="w-full h-full flex items-center gap-2">
<div className="w-16 h-12 flex-shrink-0">
<ImageTimelineTreatment
src={mediaItem.url}
alt={mediaItem.name}
targetAspectRatio={16 / 9}
className="rounded-sm"
backgroundType="mirror"
/>
</div>
<span className="text-xs text-foreground/80 truncate flex-1">
{clip.name}
</span>
</div>
);
}
if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
return (
<div className="w-full h-full flex items-center gap-2">
<div className="w-8 h-8 flex-shrink-0">
<img
src={mediaItem.thumbnailUrl}
alt={mediaItem.name}
className="w-full h-full object-cover rounded-sm"
/>
</div>
<span className="text-xs text-foreground/80 truncate flex-1">
{clip.name}
</span>
</div>
);
}
// Fallback for audio or videos without thumbnails
return (
<span className="text-xs text-foreground/80 truncate">{clip.name}</span>
);
};
return (
<div className="flex items-center px-2">
<div className="w-24 text-xs text-muted-foreground flex-shrink-0 mr-2">
{track.name}
</div>
<div className="flex-1 h-[60px]">
{track.clips.length === 0 ? (
<div className="h-full rounded-sm border-2 border-dashed border-muted/30 flex items-center justify-center text-xs text-muted-foreground">
Drop media here
</div>
) : (
<div
className={`h-full rounded-sm border cursor-pointer transition-colors ${getTrackColor(track.type)} flex items-center px-2`}
>
<span className="text-xs text-foreground/80">
{track.clips.length} clip{track.clips.length !== 1 ? "s" : ""}
</span>
</div>
)}
<div
className={`flex-1 h-[60px] transition-colors ${
isDropping ? "bg-accent/50 border-2 border-dashed border-accent" : ""
}`}
onDragOver={handleTrackDragOver}
onDragEnter={handleTrackDragEnter}
onDragLeave={handleTrackDragLeave}
onDrop={handleTrackDrop}
>
<div className="h-full flex gap-1 track-clips-container">
{track.clips.length === 0 ? (
<div className="h-full w-full rounded-sm border-2 border-dashed border-muted/30 flex items-center justify-center text-xs text-muted-foreground">
Drop media here
</div>
) : (
track.clips.map((clip, index) => (
<div
key={clip.id}
className={`timeline-clip h-full rounded-sm border cursor-grab active:cursor-grabbing transition-colors ${getTrackColor(track.type)} flex items-center px-2 min-w-[80px] overflow-hidden`}
style={{
width: `${Math.max(80, (clip.duration / 30) * 400)}px`,
}}
draggable={true}
onDragStart={(e) => handleClipDragStart(e, clip)}
>
{renderClipContent(clip)}
</div>
))
)}
</div>
</div>
</div>
);