diff --git a/.gitignore b/.gitignore index c6c0e05..24d66f8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,11 +10,12 @@ # debug /apps/web/npm-debug.log* /apps/web/yarn-debug.log* -/apps/web/yarn-error.log* +/apps/web/yarn-error.log* # env files (can opt-in for committing if needed) /apps/web/.env* !/apps/web/.env.example # typescript -/apps/web/next-env.d.ts \ No newline at end of file +/apps/web/next-env.d.ts +/apps/web/yarn.lock diff --git a/apps/web/src/components/editor/media-panel.tsx b/apps/web/src/components/editor/media-panel.tsx index ab2e66e..0d6a1f1 100644 --- a/apps/web/src/components/editor/media-panel.tsx +++ b/apps/web/src/components/editor/media-panel.tsx @@ -10,21 +10,28 @@ import { useDragDrop } from "@/hooks/use-drag-drop"; import { useRef, useState } from "react"; import { toast } from "sonner"; +// MediaPanel lets users add, view, and drag media (images, videos, audio) into the project. +// You can upload files or drag them from your computer. Dragging from here to the timeline adds them to your video project. + export function MediaPanel() { const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); const fileInputRef = useRef(null); const [isProcessing, setIsProcessing] = useState(false); const processFiles = async (files: FileList | File[]) => { + // If no files, do nothing if (!files?.length) return; setIsProcessing(true); try { + // Process files (extract metadata, generate thumbnails, etc.) const items = await processMediaFiles(files); - items.forEach(item => { + // Add each processed media item to the store + items.forEach((item) => { addMediaItem(item); }); } catch (error) { + // Show error if processing fails console.error("File processing failed:", error); toast.error("Failed to process files"); } finally { @@ -33,37 +40,47 @@ export function MediaPanel() { }; const { isDragOver, dragProps } = useDragDrop({ + // When files are dropped, process them onDrop: processFiles, }); - const handleFileSelect = () => fileInputRef.current?.click(); + const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker const handleFileChange = (e: React.ChangeEvent) => { + // When files are selected via file picker, process them if (e.target.files) processFiles(e.target.files); - e.target.value = ""; + e.target.value = ""; // Reset input }; const handleRemove = (e: React.MouseEvent, id: string) => { + // Remove a media item from the store e.stopPropagation(); removeMediaItem(id); }; const formatDuration = (duration: number) => { + // Format seconds as mm:ss const min = Math.floor(duration / 60); const sec = Math.floor(duration % 60); return `${min}:${sec.toString().padStart(2, "0")}`; }; const startDrag = (e: React.DragEvent, item: any) => { - e.dataTransfer.setData("application/x-media-item", JSON.stringify({ - id: item.id, - type: item.type, - name: item.name, - })); + // When dragging a media item, set drag data for timeline to read + e.dataTransfer.setData( + "application/x-media-item", + JSON.stringify({ + id: item.id, + type: item.type, + name: item.name, + }) + ); e.dataTransfer.effectAllowed = "copy"; }; const renderPreview = (item: any) => { + // Render a preview for each media type (image, video, audio, unknown) + // Each preview is draggable to the timeline const baseDragProps = { draggable: true, onDragStart: (e: React.DragEvent) => startDrag(e, item), @@ -84,7 +101,10 @@ export function MediaPanel() { if (item.type === "video") { if (item.thumbnailUrl) { return ( -
+
{item.name} +
); @@ -115,18 +140,26 @@ export function MediaPanel() { if (item.type === "audio") { return ( -
+
Audio {item.duration && ( - {formatDuration(item.duration)} + + {formatDuration(item.duration)} + )}
); } return ( -
+
Unknown
@@ -135,6 +168,7 @@ export function MediaPanel() { return ( <> + {/* Hidden file input for uploading media */}
+ {/* Show overlay when dragging files over the panel */}
+ {/* Button to add/upload media */} + {/* Show remove button on hover */}
+ {clipMenuOpen === clip.id && ( +
+ + +
+ )} +
- + {/* Right trim handle */}
handleResizeStart(e, clip.id, 'right')} + onMouseDown={(e) => handleResizeStart(e, clip.id, "right")} />
); @@ -907,23 +1250,185 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom {/* Drop position indicator */} {isDraggedOver && dropPosition !== null && (
-
-
-
- {wouldOverlap ? "⚠️" : ""}{dropPosition.toFixed(1)}s +
+
+
+ {wouldOverlap ? "⚠️" : ""} + {dropPosition.toFixed(1)}s
)} )} + + {/* Track Context Menu (for empty areas) */} + {trackContextMenu && ( +
e.preventDefault()} + > + {/* Mute/Unmute option */} + +
+ )} + + {/* Clip Context Menu (for specific clips) */} + {clipContextMenu && + track.clips.some((c) => c.id === clipContextMenu.clipId) && ( +
e.preventDefault()} + > + {/* Split option */} + + {/* Delete clip option */} + +
+ )}
); } + +// Custom context menu for track actions +function TrackContextMenu({ + x, + y, + track, + onClose, + onSplit, + onMute, + onDelete, +}: { + x: number; + y: number; + track: any; + onClose: () => void; + onSplit: () => void; + onMute: () => void; + onDelete: () => void; +}) { + // Small, modern, visually appealing popup + return ( +
e.preventDefault()} + > + {/* Split option */} + + {/* Mute/Unmute option */} + + {/* Delete option */} + +
+ ); +} + + + + + + + + + + + + + + + diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 4b56e6b..c569ae1 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -15,6 +15,7 @@ export interface TimelineTrack { name: string; type: "video" | "audio" | "effects"; clips: TimelineClip[]; + muted?: boolean; } interface TimelineStore { @@ -41,6 +42,7 @@ interface TimelineStore { clipId: string, startTime: number ) => void; + toggleTrackMute: (trackId: string) => void; // Computed values getTotalDuration: () => number; @@ -55,6 +57,7 @@ export const useTimelineStore = create((set, get) => ({ name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`, type, clips: [], + muted: false, }; set((state) => ({ tracks: [...state.tracks, newTrack], @@ -125,8 +128,6 @@ export const useTimelineStore = create((set, get) => ({ }); }, - - updateClipTrim: (trackId, clipId, trimStart, trimEnd) => { set((state) => ({ tracks: state.tracks.map((track) => @@ -134,9 +135,7 @@ export const useTimelineStore = create((set, get) => ({ ? { ...track, clips: track.clips.map((clip) => - clip.id === clipId - ? { ...clip, trimStart, trimEnd } - : clip + clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip ), } : track @@ -151,9 +150,7 @@ export const useTimelineStore = create((set, get) => ({ ? { ...track, clips: track.clips.map((clip) => - clip.id === clipId - ? { ...clip, startTime } - : clip + clip.id === clipId ? { ...clip, startTime } : clip ), } : track @@ -161,13 +158,22 @@ export const useTimelineStore = create((set, get) => ({ })); }, + toggleTrackMute: (trackId) => { + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === trackId ? { ...track, muted: !track.muted } : track + ), + })); + }, + getTotalDuration: () => { const { tracks } = get(); if (tracks.length === 0) return 0; const trackEndTimes = tracks.map((track) => track.clips.reduce((maxEnd, clip) => { - const clipEnd = clip.startTime + clip.duration - clip.trimStart - clip.trimEnd; + const clipEnd = + clip.startTime + clip.duration - clip.trimStart - clip.trimEnd; return Math.max(maxEnd, clipEnd); }, 0) );