feat: Added playback to video | Mute/Unmute for each Clip
This commit is contained in:
parent
294ba01abe
commit
6ea823c37d
|
|
@ -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;
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}));
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue