feat: Added playback to video | Mute/Unmute for each Clip

This commit is contained in:
Sompalkar 2025-06-23 21:48:35 +05:30
parent 294ba01abe
commit 6ea823c37d
14 changed files with 1874 additions and 642 deletions

View File

@ -0,0 +1,8 @@
CREATE TABLE "waitlist" (
"id" text PRIMARY KEY NOT NULL,
"email" text NOT NULL,
"created_at" timestamp NOT NULL,
CONSTRAINT "waitlist_email_unique" UNIQUE("email")
);
--> statement-breakpoint
ALTER TABLE "waitlist" ENABLE ROW LEVEL SECURITY;

View File

@ -0,0 +1,358 @@
{
"id": "b7d920ca-6dd0-430f-8ee6-1d38fdf3e80f",
"prevId": "4440fb90-cf0e-4cb2-afad-08250ce3dc1e",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.accounts": {
"name": "accounts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"accounts_user_id_users_id_fk": {
"name": "accounts_user_id_users_id_fk",
"tableFrom": "accounts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"sessions_token_unique": {
"name": "sessions_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.verifications": {
"name": "verifications",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.waitlist": {
"name": "waitlist",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"waitlist_email_unique": {
"name": "waitlist_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -8,6 +8,13 @@
"when": 1750581188229,
"tag": "0000_hot_the_fallen",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1750689835736,
"tag": "0001_tricky_jackpot",
"breakpoints": true
}
]
}

View File

@ -4,3 +4,9 @@
[[plugins]]
package = "@netlify/plugin-nextjs"
[[redirects]]
from = "https://appcut.app/*"
to = "https://opencut.app/:splat"
status = 301
force = true

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,15 @@ import { useMediaStore } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { VideoPlayer } from "@/components/ui/video-player";
import { Button } from "@/components/ui/button";
import { Play, Pause, Move, RotateCw, Crop, ZoomIn, ZoomOut } from "lucide-react";
import {
Play,
Pause,
Move,
RotateCw,
Crop,
ZoomIn,
ZoomOut,
} from "lucide-react";
import { useState, useRef, useEffect, useCallback } from "react";
interface ClipTransform {
@ -25,7 +33,18 @@ interface ClipTransform {
interface DragState {
isDragging: boolean;
dragType: 'move' | 'resize-nw' | 'resize-ne' | 'resize-sw' | 'resize-se' | 'rotate' | 'scale' | 'crop-n' | 'crop-s' | 'crop-e' | 'crop-w';
dragType:
| "move"
| "resize-nw"
| "resize-ne"
| "resize-sw"
| "resize-se"
| "rotate"
| "scale"
| "crop-n"
| "crop-s"
| "crop-e"
| "crop-w";
startMouseX: number;
startMouseY: number;
startTransform: ClipTransform;
@ -37,7 +56,9 @@ export function PreviewPanel() {
const { mediaItems } = useMediaStore();
const { isPlaying, toggle, currentTime } = usePlaybackStore();
const [clipTransforms, setClipTransforms] = useState<Record<string, ClipTransform>>({});
const [clipTransforms, setClipTransforms] = useState<
Record<string, ClipTransform>
>({});
const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 }); // Default 16:9
const [dragState, setDragState] = useState<DragState | null>(null);
const previewRef = useRef<HTMLDivElement>(null);
@ -54,12 +75,14 @@ export function PreviewPanel() {
tracks.forEach((track, trackIndex) => {
track.clips.forEach((clip) => {
const clipStart = clip.startTime;
const clipEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
const clipEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (currentTime >= clipStart && currentTime < clipEnd) {
const mediaItem = clip.mediaId === "test"
? { type: "test", name: clip.name, url: "", thumbnailUrl: "" }
: mediaItems.find((item) => item.id === clip.mediaId);
const mediaItem =
clip.mediaId === "test"
? { type: "test", name: clip.name, url: "", thumbnailUrl: "" }
: mediaItems.find((item) => item.id === clip.mediaId);
if (mediaItem || clip.mediaId === "test") {
activeClips.push({
@ -82,49 +105,58 @@ export function PreviewPanel() {
// Get or create transform for a clip
const getClipTransform = (clipId: string): ClipTransform => {
return clipTransforms[clipId] || {
x: 0,
y: 0,
scale: 1,
rotation: 0,
opacity: 1,
width: 100, // Percentage of canvas
height: 100,
blendMode: 'normal',
cropTop: 0,
cropBottom: 0,
cropLeft: 0,
cropRight: 0,
};
};
// Update clip transform
const updateClipTransform = useCallback((clipId: string, updates: Partial<ClipTransform>) => {
setClipTransforms(prev => {
const currentTransform = prev[clipId] || {
return (
clipTransforms[clipId] || {
x: 0,
y: 0,
scale: 1,
rotation: 0,
opacity: 1,
width: 100,
width: 100, // Percentage of canvas
height: 100,
blendMode: 'normal',
blendMode: "normal",
cropTop: 0,
cropBottom: 0,
cropLeft: 0,
cropRight: 0,
};
}
);
};
return {
...prev,
[clipId]: { ...currentTransform, ...updates }
};
});
}, []);
// Update clip transform
const updateClipTransform = useCallback(
(clipId: string, updates: Partial<ClipTransform>) => {
setClipTransforms((prev) => {
const currentTransform = prev[clipId] || {
x: 0,
y: 0,
scale: 1,
rotation: 0,
opacity: 1,
width: 100,
height: 100,
blendMode: "normal",
cropTop: 0,
cropBottom: 0,
cropLeft: 0,
cropRight: 0,
};
return {
...prev,
[clipId]: { ...currentTransform, ...updates },
};
});
},
[]
);
// Mouse event handlers
const handleMouseDown = (e: React.MouseEvent, clipId: string, dragType: DragState['dragType']) => {
const handleMouseDown = (
e: React.MouseEvent,
clipId: string,
dragType: DragState["dragType"]
) => {
e.preventDefault();
e.stopPropagation();
@ -134,90 +166,108 @@ export function PreviewPanel() {
startMouseX: e.clientX,
startMouseY: e.clientY,
startTransform: getClipTransform(clipId),
clipId
clipId,
});
};
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!dragState || !dragState.isDragging) return;
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!dragState || !dragState.isDragging) return;
const deltaX = e.clientX - dragState.startMouseX;
const deltaY = e.clientY - dragState.startMouseY;
const { startTransform, clipId, dragType } = dragState;
const deltaX = e.clientX - dragState.startMouseX;
const deltaY = e.clientY - dragState.startMouseY;
const { startTransform, clipId, dragType } = dragState;
switch (dragType) {
case 'move':
updateClipTransform(clipId, {
x: Math.max(-100, Math.min(100, startTransform.x + deltaX * 0.3)),
y: Math.max(-100, Math.min(100, startTransform.y + deltaY * 0.3))
});
break;
switch (dragType) {
case "move":
updateClipTransform(clipId, {
x: Math.max(-100, Math.min(100, startTransform.x + deltaX * 0.3)),
y: Math.max(-100, Math.min(100, startTransform.y + deltaY * 0.3)),
});
break;
case 'resize-nw':
updateClipTransform(clipId, {
width: Math.max(20, startTransform.width - deltaX * 0.5),
height: Math.max(20, startTransform.height - deltaY * 0.5)
});
break;
case "resize-nw":
updateClipTransform(clipId, {
width: Math.max(20, startTransform.width - deltaX * 0.5),
height: Math.max(20, startTransform.height - deltaY * 0.5),
});
break;
case 'resize-ne':
updateClipTransform(clipId, {
width: Math.max(20, startTransform.width + deltaX * 0.5),
height: Math.max(20, startTransform.height - deltaY * 0.5)
});
break;
case "resize-ne":
updateClipTransform(clipId, {
width: Math.max(20, startTransform.width + deltaX * 0.5),
height: Math.max(20, startTransform.height - deltaY * 0.5),
});
break;
case 'resize-sw':
updateClipTransform(clipId, {
width: Math.max(20, startTransform.width - deltaX * 0.5),
height: Math.max(20, startTransform.height + deltaY * 0.5)
});
break;
case "resize-sw":
updateClipTransform(clipId, {
width: Math.max(20, startTransform.width - deltaX * 0.5),
height: Math.max(20, startTransform.height + deltaY * 0.5),
});
break;
case 'resize-se':
updateClipTransform(clipId, {
width: Math.max(20, startTransform.width + deltaX * 0.5),
height: Math.max(20, startTransform.height + deltaY * 0.5)
});
break;
case "resize-se":
updateClipTransform(clipId, {
width: Math.max(20, startTransform.width + deltaX * 0.5),
height: Math.max(20, startTransform.height + deltaY * 0.5),
});
break;
case 'rotate':
updateClipTransform(clipId, {
rotation: (startTransform.rotation + deltaX * 2) % 360
});
break;
case "rotate":
updateClipTransform(clipId, {
rotation: (startTransform.rotation + deltaX * 2) % 360,
});
break;
case 'scale':
updateClipTransform(clipId, {
scale: Math.max(0.1, Math.min(3, startTransform.scale + deltaX * 0.01))
});
break;
case "scale":
updateClipTransform(clipId, {
scale: Math.max(
0.1,
Math.min(3, startTransform.scale + deltaX * 0.01)
),
});
break;
case 'crop-n':
updateClipTransform(clipId, {
cropTop: Math.max(0, Math.min(40, startTransform.cropTop + deltaY * 0.2))
});
break;
case "crop-n":
updateClipTransform(clipId, {
cropTop: Math.max(
0,
Math.min(40, startTransform.cropTop + deltaY * 0.2)
),
});
break;
case 'crop-s':
updateClipTransform(clipId, {
cropBottom: Math.max(0, Math.min(40, startTransform.cropBottom - deltaY * 0.2))
});
break;
case "crop-s":
updateClipTransform(clipId, {
cropBottom: Math.max(
0,
Math.min(40, startTransform.cropBottom - deltaY * 0.2)
),
});
break;
case 'crop-e':
updateClipTransform(clipId, {
cropRight: Math.max(0, Math.min(40, startTransform.cropRight - deltaX * 0.2))
});
break;
case "crop-e":
updateClipTransform(clipId, {
cropRight: Math.max(
0,
Math.min(40, startTransform.cropRight - deltaX * 0.2)
),
});
break;
case 'crop-w':
updateClipTransform(clipId, {
cropLeft: Math.max(0, Math.min(40, startTransform.cropLeft + deltaX * 0.2))
});
break;
}
}, [dragState, updateClipTransform]);
case "crop-w":
updateClipTransform(clipId, {
cropLeft: Math.max(
0,
Math.min(40, startTransform.cropLeft + deltaX * 0.2)
),
});
break;
}
},
[dragState, updateClipTransform]
);
const handleMouseUp = useCallback(() => {
setDragState(null);
@ -226,11 +276,11 @@ export function PreviewPanel() {
// Add global mouse event listeners
useEffect(() => {
if (dragState?.isDragging) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}
}, [dragState, handleMouseMove, handleMouseUp]);
@ -252,7 +302,7 @@ export function PreviewPanel() {
opacity: 1,
width: 100,
height: 100,
blendMode: 'normal',
blendMode: "normal",
cropTop: 0,
cropBottom: 0,
cropLeft: 0,
@ -262,21 +312,19 @@ export function PreviewPanel() {
});
if (hasNewClips) {
setClipTransforms(prev => ({ ...prev, ...newTransforms }));
setClipTransforms((prev) => ({ ...prev, ...newTransforms }));
}
}, [tracks, currentTime]); // Re-run when tracks or time changes
// Render a single clip layer
const renderClipLayer = (clipData: any, index: number) => {
const { clip, mediaItem } = clipData;
const transform = getClipTransform(clip.id);
const layerStyle = {
position: 'absolute' as const,
left: '50%',
top: '50%',
position: "absolute" as const,
left: "50%",
top: "50%",
width: `${transform.width}%`,
height: `${transform.height}%`,
transform: `translate(-50%, -50%) translate(${transform.x}%, ${transform.y}%) scale(${transform.scale}) rotate(${transform.rotation}deg)`,
@ -284,13 +332,16 @@ export function PreviewPanel() {
mixBlendMode: transform.blendMode as any,
clipPath: `inset(${transform.cropTop}% ${transform.cropRight}% ${transform.cropBottom}% ${transform.cropLeft}%)`,
zIndex: index + 10,
cursor: dragState?.isDragging && dragState.clipId === clip.id ? 'grabbing' : 'grab',
userSelect: 'none' as const,
cursor:
dragState?.isDragging && dragState.clipId === clip.id
? "grabbing"
: "grab",
userSelect: "none" as const,
};
const handleClipMouseDown = (e: React.MouseEvent) => {
e.stopPropagation();
handleMouseDown(e, clip.id, 'move');
handleMouseDown(e, clip.id, "move");
};
// Handle test clips
@ -308,14 +359,34 @@ export function PreviewPanel() {
</div>
{/* Hover resize corners */}
<div className="absolute -top-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-nw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-nw'); }}></div>
<div className="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-ne-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-ne'); }}></div>
<div className="absolute -bottom-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-sw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-sw'); }}></div>
<div className="absolute -bottom-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-se-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-se'); }}></div>
<div
className="absolute -top-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-nw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => {
e.stopPropagation();
handleMouseDown(e, clip.id, "resize-nw");
}}
></div>
<div
className="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-ne-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => {
e.stopPropagation();
handleMouseDown(e, clip.id, "resize-ne");
}}
></div>
<div
className="absolute -bottom-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-sw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => {
e.stopPropagation();
handleMouseDown(e, clip.id, "resize-sw");
}}
></div>
<div
className="absolute -bottom-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-se-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => {
e.stopPropagation();
handleMouseDown(e, clip.id, "resize-se");
}}
></div>
</div>
);
}
@ -323,7 +394,12 @@ export function PreviewPanel() {
// Render video
if (mediaItem.type === "video") {
return (
<div key={clip.id} style={layerStyle} onMouseDown={handleClipMouseDown} className="group">
<div
key={clip.id}
style={layerStyle}
onMouseDown={handleClipMouseDown}
className="group"
>
<VideoPlayer
src={mediaItem.url}
poster={mediaItem.thumbnailUrl}
@ -332,17 +408,38 @@ export function PreviewPanel() {
trimStart={clip.trimStart}
trimEnd={clip.trimEnd}
clipDuration={clip.duration}
muted={!!clip.muted}
/>
{/* Hover resize corners */}
<div className="absolute -top-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-nw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-nw'); }}></div>
<div className="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-ne-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-ne'); }}></div>
<div className="absolute -bottom-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-sw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-sw'); }}></div>
<div className="absolute -bottom-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-se-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-se'); }}></div>
<div
className="absolute -top-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-nw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => {
e.stopPropagation();
handleMouseDown(e, clip.id, "resize-nw");
}}
></div>
<div
className="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-ne-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => {
e.stopPropagation();
handleMouseDown(e, clip.id, "resize-ne");
}}
></div>
<div
className="absolute -bottom-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-sw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => {
e.stopPropagation();
handleMouseDown(e, clip.id, "resize-sw");
}}
></div>
<div
className="absolute -bottom-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-se-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => {
e.stopPropagation();
handleMouseDown(e, clip.id, "resize-se");
}}
></div>
</div>
);
}
@ -350,7 +447,12 @@ export function PreviewPanel() {
// Render image
if (mediaItem.type === "image") {
return (
<div key={clip.id} style={layerStyle} onMouseDown={handleClipMouseDown} className="group">
<div
key={clip.id}
style={layerStyle}
onMouseDown={handleClipMouseDown}
className="group"
>
<img
src={mediaItem.url}
alt={mediaItem.name}
@ -359,14 +461,34 @@ export function PreviewPanel() {
/>
{/* Hover resize corners */}
<div className="absolute -top-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-nw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-nw'); }}></div>
<div className="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-ne-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-ne'); }}></div>
<div className="absolute -bottom-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-sw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-sw'); }}></div>
<div className="absolute -bottom-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-se-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-se'); }}></div>
<div
className="absolute -top-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-nw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => {
e.stopPropagation();
handleMouseDown(e, clip.id, "resize-nw");
}}
></div>
<div
className="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-ne-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => {
e.stopPropagation();
handleMouseDown(e, clip.id, "resize-ne");
}}
></div>
<div
className="absolute -bottom-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-sw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => {
e.stopPropagation();
handleMouseDown(e, clip.id, "resize-sw");
}}
></div>
<div
className="absolute -bottom-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-se-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => {
e.stopPropagation();
handleMouseDown(e, clip.id, "resize-se");
}}
></div>
</div>
);
}
@ -386,14 +508,34 @@ export function PreviewPanel() {
</div>
{/* Hover resize corners */}
<div className="absolute -top-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-nw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-nw'); }}></div>
<div className="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-ne-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-ne'); }}></div>
<div className="absolute -bottom-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-sw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-sw'); }}></div>
<div className="absolute -bottom-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-se-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-se'); }}></div>
<div
className="absolute -top-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-nw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => {
e.stopPropagation();
handleMouseDown(e, clip.id, "resize-nw");
}}
></div>
<div
className="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-ne-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => {
e.stopPropagation();
handleMouseDown(e, clip.id, "resize-ne");
}}
></div>
<div
className="absolute -bottom-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-sw-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => {
e.stopPropagation();
handleMouseDown(e, clip.id, "resize-sw");
}}
></div>
<div
className="absolute -bottom-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-se-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => {
e.stopPropagation();
handleMouseDown(e, clip.id, "resize-se");
}}
></div>
</div>
);
}
@ -418,13 +560,19 @@ export function PreviewPanel() {
<select
value={`${canvasSize.width}x${canvasSize.height}`}
onChange={(e) => {
const preset = canvasPresets.find(p => `${p.width}x${p.height}` === e.target.value);
if (preset) setCanvasSize({ width: preset.width, height: preset.height });
const preset = canvasPresets.find(
(p) => `${p.width}x${p.height}` === e.target.value
);
if (preset)
setCanvasSize({ width: preset.width, height: preset.height });
}}
className="bg-background border rounded px-2 py-1"
>
{canvasPresets.map(preset => (
<option key={preset.name} value={`${preset.width}x${preset.height}`}>
{canvasPresets.map((preset) => (
<option
key={preset.name}
value={`${preset.width}x${preset.height}`}
>
{preset.name} ({preset.width}×{preset.height})
</option>
))}
@ -436,7 +584,11 @@ export function PreviewPanel() {
onClick={toggle}
className="ml-auto"
>
{isPlaying ? <Pause className="h-3 w-3 mr-1" /> : <Play className="h-3 w-3 mr-1" />}
{isPlaying ? (
<Pause className="h-3 w-3 mr-1" />
) : (
<Play className="h-3 w-3 mr-1" />
)}
{isPlaying ? "Pause" : "Play"}
</Button>
</div>
@ -452,23 +604,22 @@ export function PreviewPanel() {
height: aspectRatio <= 1 ? "100%" : "auto",
maxWidth: "100%",
maxHeight: "100%",
background: '#000000',
border: '1px solid #374151'
background: "#000000",
border: "1px solid #374151",
}}
>
{/* Render all active clips as layers */}
{activeClips.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-white/50">
{tracks.length === 0 ? "Drop media to start editing" : "No clips at current time"}
{tracks.length === 0
? "Drop media to start editing"
: "No clips at current time"}
</div>
) : (
activeClips.map((clipData, index) => renderClipLayer(clipData, index))
activeClips.map((clipData, index) =>
renderClipLayer(clipData, index)
)
)}
</div>
</div>
@ -476,7 +627,9 @@ export function PreviewPanel() {
<div className="border-t bg-background">
{/* Layer List */}
<div className="p-2 border-b">
<div className="text-xs font-medium mb-2">Active Layers ({activeClips.length})</div>
<div className="text-xs font-medium mb-2">
Active Layers ({activeClips.length})
</div>
<div className="space-y-1 max-h-20 overflow-y-auto">
{activeClips.map((clipData, index) => (
<div
@ -487,13 +640,13 @@ export function PreviewPanel() {
{index + 1}
</span>
<span className="flex-1 truncate">{clipData.clip.name}</span>
<span className="text-muted-foreground">{clipData.track.name}</span>
<span className="text-muted-foreground">
{clipData.track.name}
</span>
</div>
))}
</div>
</div>
</div>
</div>
);

View File

@ -16,6 +16,7 @@ import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
import { useState } from "react";
import { SpeedControl } from "./speed-control";
export function PropertiesPanel() {
const { tracks } = useTimelineStore();
@ -25,6 +26,18 @@ export function PropertiesPanel() {
>("blur");
const [backgroundColor, setBackgroundColor] = useState("#000000");
// Get the first video clip for preview (simplified)
const firstVideoClip = tracks
.flatMap((track) => track.clips)
.find((clip) => {
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
return mediaItem?.type === "video";
});
const firstVideoItem = firstVideoClip
? mediaItems.find((item) => item.id === firstVideoClip.mediaId)
: null;
// Get the first image clip for preview (simplified)
const firstImageClip = tracks
.flatMap((track) => track.clips)
@ -107,6 +120,14 @@ export function PropertiesPanel() {
</>
)}
{/* Video Controls - only show if a video is selected */}
{firstVideoItem && (
<>
<SpeedControl />
<Separator />
</>
)}
{/* Transform */}
<div className="space-y-4">
<h3 className="text-sm font-medium">Transform</h3>

View File

@ -0,0 +1,46 @@
import { Slider } from "../ui/slider";
import { Label } from "../ui/label";
import { Button } from "../ui/button";
import { usePlaybackStore } from "@/stores/playback-store";
const SPEED_PRESETS = [
{ label: "0.5x", value: 0.5 },
{ label: "1x", value: 1.0 },
{ label: "1.5x", value: 1.5 },
{ label: "2x", value: 2.0 },
];
export function SpeedControl() {
const { speed, setSpeed } = usePlaybackStore();
return (
<div className="space-y-4">
<h3 className="text-sm font-medium">Playback Speed</h3>
<div className="space-y-4">
<div className="flex gap-2">
{SPEED_PRESETS.map((preset) => (
<Button
key={preset.value}
variant={speed === preset.value ? "default" : "outline"}
className="flex-1"
onClick={() => setSpeed(preset.value)}
>
{preset.label}
</Button>
))}
</div>
<div className="space-y-1">
<Label>Custom ({speed.toFixed(1)}x)</Label>
<Slider
value={[speed]}
min={0.1}
max={2.0}
step={0.1}
onValueChange={(value) => setSpeed(value[0])}
className="mt-2"
/>
</div>
</div>
</div>
);
}

View File

@ -28,15 +28,43 @@ import { usePlaybackStore } from "@/stores/playback-store";
import { processMediaFiles } from "@/lib/media-processing";
import { toast } from "sonner";
import { useState, useRef, useEffect } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their clips.
// You can drag media here to add it to your project.
// Clips can be trimmed, deleted, and moved.
const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack, getTotalDuration } =
useTimelineStore();
const {
tracks,
addTrack,
addClipToTrack,
removeTrack,
toggleTrackMute,
removeClipFromTrack,
moveClipToTrack,
getTotalDuration,
selectedClip,
clearSelectedClip,
} = useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore();
const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle } = usePlaybackStore();
const {
currentTime,
duration,
seek,
setDuration,
isPlaying,
play,
pause,
toggle,
setSpeed,
speed,
} = usePlaybackStore();
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [zoomLevel, setZoomLevel] = useState(1);
@ -45,7 +73,7 @@ export function Timeline() {
// Unified context menu state
const [contextMenu, setContextMenu] = useState<{
type: 'track' | 'clip';
type: "track" | "clip";
trackId: string;
clipId?: string;
x: number;
@ -67,6 +95,18 @@ export function Timeline() {
}
}, [contextMenu]);
// Keyboard event for deleting selected clip
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.key === "Delete" || e.key === "Backspace") && selectedClip) {
removeClipFromTrack(selectedClip.trackId, selectedClip.clipId);
clearSelectedClip();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedClip, removeClipFromTrack, clearSelectedClip]);
const handleDragEnter = (e: React.DragEvent) => {
// When something is dragged over the timeline, show overlay
e.preventDefault();
@ -105,7 +145,9 @@ export function Timeline() {
dragCounterRef.current = 0;
// Ignore timeline clip drags - they're handled by track-specific handlers
const hasTimelineClip = e.dataTransfer.types.includes("application/x-timeline-clip");
const hasTimelineClip = e.dataTransfer.types.includes(
"application/x-timeline-clip"
);
if (hasTimelineClip) {
return;
}
@ -225,17 +267,24 @@ export function Timeline() {
onClick={toggle}
className="mr-2"
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
{isPlaying ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{isPlaying ? "Pause (Space)" : "Play (Space)"}</TooltipContent>
<TooltipContent>
{isPlaying ? "Pause (Space)" : "Play (Space)"}
</TooltipContent>
</Tooltip>
<div className="w-px h-6 bg-border mx-1" />
{/* Time Display */}
<div className="text-xs text-muted-foreground font-mono px-2">
{Math.floor(currentTime * 10) / 10}s / {Math.floor(duration * 10) / 10}s
{Math.floor(currentTime * 10) / 10}s /{" "}
{Math.floor(duration * 10) / 10}s
</div>
<div className="w-px h-6 bg-border mx-1" />
@ -331,6 +380,29 @@ export function Timeline() {
</TooltipTrigger>
<TooltipContent>Delete clip (Delete)</TooltipContent>
</Tooltip>
<div className="w-px h-6 bg-border mx-1" />
{/* Speed Control */}
<Tooltip>
<TooltipTrigger asChild>
<Select
value={speed.toFixed(1)}
onValueChange={(value) => setSpeed(parseFloat(value))}
>
<SelectTrigger className="w-[90px] h-8">
<SelectValue placeholder="1.0x" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.5">0.5x</SelectItem>
<SelectItem value="1.0">1.0x</SelectItem>
<SelectItem value="1.5">1.5x</SelectItem>
<SelectItem value="2.0">2.0x</SelectItem>
</SelectContent>
</Select>
</TooltipTrigger>
<TooltipContent>Playback Speed</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
@ -356,6 +428,7 @@ export function Timeline() {
style={{
width: `${Math.max(1000, duration * 50 * zoomLevel)}px`,
}}
onClick={handleTimelineClick}
>
{/* Time markers */}
{(() => {
@ -384,17 +457,19 @@ export function Timeline() {
return (
<div
key={i}
className={`absolute top-0 bottom-0 ${isMainMarker
? "border-l border-muted-foreground/40"
: "border-l border-muted-foreground/20"
}`}
className={`absolute top-0 bottom-0 ${
isMainMarker
? "border-l border-muted-foreground/40"
: "border-l border-muted-foreground/20"
}`}
style={{ left: `${time * 50 * zoomLevel}px` }}
>
<span
className={`absolute top-1 left-1 text-xs ${isMainMarker
? "text-muted-foreground font-medium"
: "text-muted-foreground/70"
}`}
className={`absolute top-1 left-1 text-xs ${
isMainMarker
? "text-muted-foreground font-medium"
: "text-muted-foreground/70"
}`}
>
{(() => {
const formatTime = (seconds: number) => {
@ -455,12 +530,13 @@ export function Timeline() {
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div
className={`w-3 h-3 rounded-full flex-shrink-0 ${track.type === "video"
? "bg-blue-500"
: track.type === "audio"
? "bg-green-500"
: "bg-purple-500"
}`}
className={`w-3 h-3 rounded-full flex-shrink-0 ${
track.type === "video"
? "bg-blue-500"
: track.type === "audio"
? "bg-green-500"
: "bg-purple-500"
}`}
/>
<span className="text-sm font-medium truncate">
{track.name}
@ -516,7 +592,7 @@ export function Timeline() {
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({
type: 'track',
type: "track",
trackId: track.id,
x: e.clientX,
y: e.clientY,
@ -528,7 +604,6 @@ export function Timeline() {
zoomLevel={zoomLevel}
setContextMenu={setContextMenu}
/>
</div>
))}
@ -555,19 +630,23 @@ export function Timeline() {
style={{ left: contextMenu.x, top: contextMenu.y }}
onContextMenu={(e) => e.preventDefault()}
>
{contextMenu.type === 'track' ? (
{contextMenu.type === "track" ? (
// Track context menu
<>
<button
className="flex items-center w-full px-3 py-2 hover:bg-accent hover:text-accent-foreground transition-colors text-left"
onClick={() => {
const track = tracks.find(t => t.id === contextMenu.trackId);
const track = tracks.find(
(t) => t.id === contextMenu.trackId
);
if (track) toggleTrackMute(track.id);
setContextMenu(null);
}}
>
{(() => {
const track = tracks.find(t => t.id === contextMenu.trackId);
const track = tracks.find(
(t) => t.id === contextMenu.trackId
);
return track?.muted ? (
<>
<Volume2 className="h-4 w-4 mr-2" />
@ -601,26 +680,37 @@ export function Timeline() {
className="flex items-center w-full px-3 py-2 hover:bg-accent hover:text-accent-foreground transition-colors text-left"
onClick={() => {
if (contextMenu.clipId) {
const track = tracks.find(t => t.id === contextMenu.trackId);
const clip = track?.clips.find(c => c.id === contextMenu.clipId);
const track = tracks.find(
(t) => t.id === contextMenu.trackId
);
const clip = track?.clips.find(
(c) => c.id === contextMenu.clipId
);
if (clip && track) {
const splitTime = currentTime;
const effectiveStart = clip.startTime;
const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
useTimelineStore.getState().updateClipTrim(
track.id,
clip.id,
clip.trimStart,
clip.trimEnd + (effectiveEnd - splitTime)
);
const effectiveEnd =
clip.startTime +
(clip.duration - clip.trimStart - clip.trimEnd);
if (
splitTime > effectiveStart &&
splitTime < effectiveEnd
) {
useTimelineStore
.getState()
.updateClipTrim(
track.id,
clip.id,
clip.trimStart,
clip.trimEnd + (effectiveEnd - splitTime)
);
useTimelineStore.getState().addClipToTrack(track.id, {
mediaId: clip.mediaId,
name: clip.name + " (split)",
duration: clip.duration,
startTime: splitTime,
trimStart: clip.trimStart + (splitTime - effectiveStart),
trimStart:
clip.trimStart + (splitTime - effectiveStart),
trimEnd: clip.trimEnd,
});
toast.success("Clip split successfully");
@ -639,14 +729,61 @@ export function Timeline() {
className="flex items-center w-full px-3 py-2 hover:bg-accent hover:text-accent-foreground transition-colors text-left"
onClick={() => {
if (contextMenu.clipId) {
const track = tracks.find(t => t.id === contextMenu.trackId);
const clip = track?.clips.find(c => c.id === contextMenu.clipId);
const track = tracks.find(
(t) => t.id === contextMenu.trackId
);
const clip = track?.clips.find(
(c) => c.id === contextMenu.clipId
);
if (clip && track) {
useTimelineStore
.getState()
.toggleClipMute(track.id, clip.id);
toast.success(clip.muted ? "Clip unmuted" : "Clip muted");
}
}
setContextMenu(null);
}}
>
{(() => {
const track = tracks.find(
(t) => t.id === contextMenu.trackId
);
const clip = track?.clips.find(
(c) => c.id === contextMenu.clipId
);
return clip?.muted ? (
<>
<Volume2 className="h-4 w-4 mr-2" />
Unmute Clip
</>
) : (
<>
<VolumeX className="h-4 w-4 mr-2" />
Mute Clip
</>
);
})()}
</button>
<button
className="flex items-center w-full px-3 py-2 hover:bg-accent hover:text-accent-foreground transition-colors text-left"
onClick={() => {
if (contextMenu.clipId) {
const track = tracks.find(
(t) => t.id === contextMenu.trackId
);
const clip = track?.clips.find(
(c) => c.id === contextMenu.clipId
);
if (clip && track) {
useTimelineStore.getState().addClipToTrack(track.id, {
mediaId: clip.mediaId,
name: clip.name + " (copy)",
duration: clip.duration,
startTime: clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd) + 0.1,
startTime:
clip.startTime +
(clip.duration - clip.trimStart - clip.trimEnd) +
0.1,
trimStart: clip.trimStart,
trimEnd: clip.trimEnd,
});
@ -664,7 +801,10 @@ export function Timeline() {
className="flex items-center w-full px-3 py-2 text-destructive hover:bg-destructive/10 transition-colors text-left"
onClick={() => {
if (contextMenu.clipId) {
removeClipFromTrack(contextMenu.trackId, contextMenu.clipId);
removeClipFromTrack(
contextMenu.trackId,
contextMenu.clipId
);
toast.success("Clip deleted");
}
setContextMenu(null);
@ -688,7 +828,15 @@ function TimelineTrackContent({
}: {
track: TimelineTrack;
zoomLevel: number;
setContextMenu: (menu: { type: 'track' | 'clip'; trackId: string; clipId?: string; x: number; y: number; } | null) => void;
setContextMenu: (
menu: {
type: "track" | "clip";
trackId: string;
clipId?: string;
x: number;
y: number;
} | null
) => void;
}) {
const { mediaItems } = useMediaStore();
const {
@ -699,6 +847,8 @@ function TimelineTrackContent({
addClipToTrack,
removeClipFromTrack,
toggleTrackMute,
selectedClip,
selectClip,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
const [isDropping, setIsDropping] = useState(false);
@ -830,7 +980,7 @@ function TimelineTrackContent({
return;
}
}
} catch (error) { }
} catch (error) {}
}
// Calculate drop position for overlap checking
@ -1208,18 +1358,19 @@ function TimelineTrackContent({
return (
<div
className={`w-full h-full transition-all duration-150 ease-out ${isDraggedOver
? wouldOverlap
? "bg-red-500/15 border-2 border-dashed border-red-400 shadow-lg"
: "bg-blue-500/15 border-2 border-dashed border-blue-400 shadow-lg"
: "hover:bg-muted/20"
}`}
className={`w-full h-full transition-all duration-150 ease-out ${
isDraggedOver
? wouldOverlap
? "bg-red-500/15 border-2 border-dashed border-red-400 shadow-lg"
: "bg-blue-500/15 border-2 border-dashed border-blue-400 shadow-lg"
: "hover:bg-muted/20"
}`}
onContextMenu={(e) => {
e.preventDefault();
// Only show track menu if we didn't click on a clip
if (!(e.target as HTMLElement).closest(".timeline-clip")) {
setContextMenu({
type: 'track',
type: "track",
trackId: track.id,
x: e.clientX,
y: e.clientY,
@ -1237,12 +1388,13 @@ function TimelineTrackContent({
<div className="h-full relative track-clips-container min-w-full">
{track.clips.length === 0 ? (
<div
className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${isDropping
? wouldOverlap
? "border-red-500 bg-red-500/10 text-red-600"
: "border-blue-500 bg-blue-500/10 text-blue-600"
: "border-muted/30"
}`}
className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
isDropping
? wouldOverlap
? "border-red-500 bg-red-500/10 text-red-600"
: "border-blue-500 bg-blue-500/10 text-blue-600"
: "border-muted/30"
}`}
>
{isDropping
? wouldOverlap
@ -1261,16 +1413,27 @@ function TimelineTrackContent({
);
const clipLeft = clip.startTime * 50 * zoomLevel;
// Correctly declare isSelected inside the map
const isSelected =
selectedClip &&
selectedClip.trackId === track.id &&
selectedClip.clipId === clip.id;
return (
<div
key={clip.id}
className={`timeline-clip absolute h-full rounded-sm border transition-all duration-200 ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg`}
className={`timeline-clip absolute h-full rounded-sm border transition-all duration-200 ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg ${isSelected ? "ring-2 ring-blue-500 z-10" : ""}`}
style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
onClick={(e) => {
e.stopPropagation();
selectClip(track.id, clip.id);
}}
tabIndex={0}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
type: 'clip',
type: "clip",
trackId: track.id,
clipId: clip.id,
x: e.clientX,
@ -1334,24 +1497,22 @@ function TimelineTrackContent({
{/* Drop position indicator */}
{isDraggedOver && dropPosition !== null && (
<div
className={`absolute top-0 bottom-0 w-1 pointer-events-none z-30 transition-all duration-75 ease-out ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
}`}
className={`absolute top-0 bottom-0 w-1 pointer-events-none z-30 transition-all duration-75 ease-out ${
wouldOverlap ? "bg-red-500" : "bg-blue-500"
}`}
style={{
left: `${dropPosition * 50 * zoomLevel}px`,
transform: "translateX(-50%)",
}}
>
<div
className={`absolute -top-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
}`}
className={`absolute -top-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
/>
<div
className={`absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
}`}
className={`absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
/>
<div
className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-xs text-white px-1 py-0.5 rounded whitespace-nowrap ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
}`}
className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-xs text-white px-1 py-0.5 rounded whitespace-nowrap ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
>
{wouldOverlap ? "⚠️" : ""}
{dropPosition.toFixed(1)}s
@ -1363,4 +1524,4 @@ function TimelineTrackContent({
</div>
</div>
);
}
}

View File

@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import { type DialogProps } from "radix-ui";
import { DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";

View File

@ -6,132 +6,179 @@ import { Play, Pause, Volume2 } from "lucide-react";
import { usePlaybackStore } from "@/stores/playback-store";
interface VideoPlayerProps {
src: string;
poster?: string;
className?: string;
clipStartTime: number;
trimStart: number;
trimEnd: number;
clipDuration: number;
src: string;
poster?: string;
className?: string;
clipStartTime: number;
trimStart: number;
trimEnd: number;
clipDuration: number;
muted?: boolean;
}
export function VideoPlayer({
src,
poster,
className = "",
clipStartTime,
trimStart,
trimEnd,
clipDuration
src,
poster,
className = "",
clipStartTime,
trimStart,
trimEnd,
clipDuration,
muted = false,
}: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const { isPlaying, currentTime, volume, play, pause, setVolume } = usePlaybackStore();
const videoRef = useRef<HTMLVideoElement>(null);
const { isPlaying, currentTime, volume, speed, play, pause, setVolume } =
usePlaybackStore();
// Calculate if we're within this clip's timeline range
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
const isInClipRange = currentTime >= clipStartTime && currentTime < clipEndTime;
// Calculate if we're within this clip's timeline range
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
const isInClipRange =
currentTime >= clipStartTime && currentTime < clipEndTime;
// Calculate the video's internal time based on timeline position
const videoTime = Math.max(trimStart, Math.min(
clipDuration - trimEnd,
currentTime - clipStartTime + trimStart
));
// Calculate the video's internal time based on timeline position
const videoTime = Math.max(
trimStart,
Math.min(clipDuration - trimEnd, currentTime - clipStartTime + trimStart)
);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleSeekEvent = (e: CustomEvent) => {
if (!isInClipRange) return;
const timelineTime = e.detail.time;
const newVideoTime = Math.max(trimStart, Math.min(
clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
));
video.currentTime = newVideoTime;
};
const handleSeekEvent = (e: CustomEvent) => {
if (!isInClipRange) return;
const timelineTime = e.detail.time;
const newVideoTime = Math.max(
trimStart,
Math.min(
clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
)
);
video.currentTime = newVideoTime;
};
const handleUpdateEvent = (e: CustomEvent) => {
if (!isInClipRange) return;
const timelineTime = e.detail.time;
const targetVideoTime = Math.max(trimStart, Math.min(
clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
));
const handleUpdateEvent = (e: CustomEvent) => {
if (!isInClipRange) return;
const timelineTime = e.detail.time;
const targetVideoTime = Math.max(
trimStart,
Math.min(
clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
)
);
// Only sync if there's a significant difference
if (Math.abs(video.currentTime - targetVideoTime) > 0.2) {
video.currentTime = targetVideoTime;
}
};
// Only sync if there's a significant difference to avoid micro-adjustments
if (Math.abs(video.currentTime - targetVideoTime) > 0.5) {
video.currentTime = targetVideoTime;
}
};
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
window.addEventListener("playback-update", handleUpdateEvent as EventListener);
const handleSpeedEvent = (e: CustomEvent) => {
if (!isInClipRange) return;
// Set playbackRate directly without any additional checks
video.playbackRate = e.detail.speed;
};
return () => {
window.removeEventListener("playback-seek", handleSeekEvent as EventListener);
window.removeEventListener("playback-update", handleUpdateEvent as EventListener);
};
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
// Sync video playback state - only play if in clip range
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (isPlaying && isInClipRange) {
video.play().catch(console.error);
} else {
video.pause();
}
}, [isPlaying, isInClipRange]);
// Sync volume
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.volume = volume;
}, [volume]);
return (
<div className={`relative group ${className}`}>
<video
ref={videoRef}
src={src}
poster={poster}
className="w-full h-full object-cover"
playsInline
preload="metadata"
/>
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
<div className="absolute bottom-2 left-2 right-2 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 bg-black/50 text-white hover:bg-black/70"
onClick={isPlaying ? pause : play}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<div className="flex-1 h-1 bg-white/30 rounded-full overflow-hidden">
<div
className="h-full bg-white transition-all duration-100"
style={{ width: `${(currentTime / usePlaybackStore.getState().duration) * 100}%` }}
/>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 bg-black/50 text-white hover:bg-black/70"
onClick={() => setVolume(volume > 0 ? 0 : 1)}
>
<Volume2 className="h-4 w-4" />
</Button>
</div>
</div>
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
window.addEventListener(
"playback-update",
handleUpdateEvent as EventListener
);
}
window.addEventListener(
"playback-speed",
handleSpeedEvent as EventListener
);
return () => {
window.removeEventListener(
"playback-seek",
handleSeekEvent as EventListener
);
window.removeEventListener(
"playback-update",
handleUpdateEvent as EventListener
);
window.removeEventListener(
"playback-speed",
handleSpeedEvent as EventListener
);
};
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
// Sync video playback state - only play if in clip range
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (isPlaying && isInClipRange) {
video.play().catch(console.error);
} else {
video.pause();
}
}, [isPlaying, isInClipRange]);
// Sync volume
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.volume = volume;
}, [volume]);
// Sync speed immediately when it changes
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.playbackRate = speed;
}, [speed]);
return (
<div className={`relative group ${className}`}>
<video
ref={videoRef}
src={src}
poster={poster}
className="w-full h-full object-cover"
playsInline
preload="auto"
muted={muted}
/>
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
<div className="absolute bottom-2 left-2 right-2 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 bg-black/50 text-white hover:bg-black/70"
onClick={isPlaying ? pause : play}
>
{isPlaying ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<div className="flex-1 h-1 bg-white/30 rounded-full overflow-hidden">
<div
className="h-full bg-white transition-all duration-100"
style={{
width: `${(currentTime / usePlaybackStore.getState().duration) * 100}%`,
}}
/>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 bg-black/50 text-white hover:bg-black/70"
onClick={() => setVolume(volume > 0 ? 0 : 1)}
>
<Volume2 className="h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@ -6,29 +6,46 @@ interface PlaybackStore extends PlaybackState, PlaybackControls {
setCurrentTime: (time: number) => void;
}
let playbackTimer: NodeJS.Timeout | null = null;
let playbackTimer: number | null = null;
const startTimer = (store: any) => {
if (playbackTimer) clearInterval(playbackTimer);
playbackTimer = setInterval(() => {
if (playbackTimer) cancelAnimationFrame(playbackTimer);
// Use requestAnimationFrame for smoother updates
const updateTime = () => {
const state = store();
if (state.isPlaying && state.currentTime < state.duration) {
const newTime = state.currentTime + 0.1;
const now = performance.now();
const delta = (now - lastUpdate) / 1000; // Convert to seconds
lastUpdate = now;
const newTime = state.currentTime + delta * state.speed;
if (newTime >= state.duration) {
state.pause();
// Loop: restart from 0 and keep playing
state.seek(0);
state.setCurrentTime(0);
// Notify video elements to sync
window.dispatchEvent(
new CustomEvent("playback-update", { detail: { time: 0 } })
);
} else {
state.setCurrentTime(newTime);
// Notify video elements to sync
window.dispatchEvent(new CustomEvent('playback-update', { detail: { time: newTime } }));
window.dispatchEvent(
new CustomEvent("playback-update", { detail: { time: newTime } })
);
}
}
}, 100);
playbackTimer = requestAnimationFrame(updateTime);
};
let lastUpdate = performance.now();
playbackTimer = requestAnimationFrame(updateTime);
};
const stopTimer = () => {
if (playbackTimer) {
clearInterval(playbackTimer);
cancelAnimationFrame(playbackTimer);
playbackTimer = null;
}
};
@ -38,6 +55,7 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
currentTime: 0,
duration: 0,
volume: 1,
speed: 1.0,
play: () => {
set({ isPlaying: true });
@ -62,12 +80,27 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
const { duration } = get();
const clampedTime = Math.max(0, Math.min(duration, time));
set({ currentTime: clampedTime });
// Notify video elements to seek
window.dispatchEvent(new CustomEvent('playback-seek', { detail: { time: clampedTime } }));
const event = new CustomEvent("playback-seek", {
detail: { time: clampedTime },
});
window.dispatchEvent(event);
},
setVolume: (volume: number) => set({ volume: Math.max(0, Math.min(1, volume)) }),
setVolume: (volume: number) =>
set({ volume: Math.max(0, Math.min(1, volume)) }),
setSpeed: (speed: number) => {
const newSpeed = Math.max(0.1, Math.min(2.0, speed));
set({ speed: newSpeed });
const event = new CustomEvent("playback-speed", {
detail: { speed: newSpeed },
});
window.dispatchEvent(event);
},
setDuration: (duration: number) => set({ duration }),
setCurrentTime: (time: number) => set({ currentTime: time }),
}));
}));

View File

@ -8,6 +8,7 @@ export interface TimelineClip {
startTime: number;
trimStart: number;
trimEnd: number;
muted?: boolean;
}
export interface TimelineTrack {
@ -21,6 +22,11 @@ export interface TimelineTrack {
interface TimelineStore {
tracks: TimelineTrack[];
// Selection
selectedClip: { trackId: string; clipId: string } | null;
selectClip: (trackId: string, clipId: string) => void;
clearSelectedClip: () => void;
// Actions
addTrack: (type: "video" | "audio" | "effects") => string;
removeTrack: (trackId: string) => void;
@ -43,6 +49,7 @@ interface TimelineStore {
startTime: number
) => void;
toggleTrackMute: (trackId: string) => void;
toggleClipMute: (trackId: string, clipId: string) => void;
// Computed values
getTotalDuration: () => number;
@ -50,6 +57,14 @@ interface TimelineStore {
export const useTimelineStore = create<TimelineStore>((set, get) => ({
tracks: [],
selectedClip: null,
selectClip: (trackId, clipId) => {
set({ selectedClip: { trackId, clipId } });
},
clearSelectedClip: () => {
set({ selectedClip: null });
},
addTrack: (type) => {
const newTrack: TimelineTrack = {
@ -78,6 +93,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
startTime: clipData.startTime || 0,
trimStart: 0,
trimEnd: 0,
muted: false,
};
set((state) => ({
@ -166,6 +182,21 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
}));
},
toggleClipMute: (trackId, clipId) => {
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
clips: track.clips.map((clip) =>
clip.id === clipId ? { ...clip, muted: !clip.muted } : clip
),
}
: track
),
}));
},
getTotalDuration: () => {
const { tracks } = get();
if (tracks.length === 0) return 0;

View File

@ -3,6 +3,7 @@ export interface PlaybackState {
currentTime: number;
duration: number;
volume: number;
speed: number;
}
export interface PlaybackControls {
@ -10,5 +11,6 @@ export interface PlaybackControls {
pause: () => void;
seek: (time: number) => void;
setVolume: (volume: number) => void;
setSpeed: (speed: number) => void;
toggle: () => void;
}