diff --git a/apps/web/bun.lock b/apps/web/bun.lock index 6603ef6..8f18f03 100644 --- a/apps/web/bun.lock +++ b/apps/web/bun.lock @@ -13,7 +13,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", - "date-fns": "^3.6.0", + "dayjs": "^1.11.13", "dotenv": "^16.5.0", "drizzle-orm": "^0.44.2", "embla-carousel-react": "^8.5.1", @@ -488,6 +488,8 @@ "date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], + "dayjs": ["dayjs@1.11.13", "", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="], + "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], diff --git a/apps/web/package.json b/apps/web/package.json index 2b15eb7..07b4219 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,7 +21,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", - "date-fns": "^3.6.0", + "dayjs": "^1.11.13", "dotenv": "^16.5.0", "drizzle-orm": "^0.44.2", "embla-carousel-react": "^8.5.1", diff --git a/apps/web/src/app/editor/page.tsx b/apps/web/src/app/editor/page.tsx index cc2db10..2f825a2 100644 --- a/apps/web/src/app/editor/page.tsx +++ b/apps/web/src/app/editor/page.tsx @@ -14,6 +14,7 @@ import { EditorHeader } from "@/components/editor-header"; import { usePanelStore } from "@/stores/panel-store"; import { useProjectStore } from "@/stores/project-store"; import { EditorProvider } from "@/components/editor-provider"; +import { usePlaybackControls } from "@/hooks/use-playback-controls"; export default function Editor() { const { @@ -31,7 +32,8 @@ export default function Editor() { const { activeProject, createNewProject } = useProjectStore(); - // Initialize a new project if none exists + usePlaybackControls(); + useEffect(() => { if (!activeProject) { createNewProject("Untitled Project"); diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 16c808f..593758e 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -2,66 +2,69 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { useMediaStore } from "@/stores/media-store"; +import { usePlaybackStore } from "@/stores/playback-store"; import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment"; +import { VideoPlayer } from "@/components/ui/video-player"; import { Button } from "@/components/ui/button"; import { Play, Pause } from "lucide-react"; -import { useState } from "react"; export function PreviewPanel() { const { tracks } = useTimelineStore(); const { mediaItems } = useMediaStore(); - const [isPlaying, setIsPlaying] = useState(false); + const { isPlaying, toggle } = usePlaybackStore(); - // Get the first clip from the first track for preview (simplified for now) const firstClip = tracks[0]?.clips[0]; const firstMediaItem = firstClip ? mediaItems.find((item) => item.id === firstClip.mediaId) : null; - // Calculate dynamic aspect ratio - default to 16:9 if no media const aspectRatio = firstMediaItem?.aspectRatio || 16 / 9; - const renderPreviewContent = () => { + const renderContent = () => { if (!firstMediaItem) { return ( -
- Drop media here or click to import +
+ Drop media to start editing
); } + if (firstMediaItem.type === "video") { + return ( + + ); + } + if (firstMediaItem.type === "image") { return ( ); } - if (firstMediaItem.type === "video") { - return firstMediaItem.thumbnailUrl ? ( - {firstMediaItem.name} - ) : ( -
- Video Preview -
- ); - } - if (firstMediaItem.type === "audio") { return (
🎵

{firstMediaItem.name}

+
); @@ -73,7 +76,7 @@ export function PreviewPanel() { return (
1 ? "100%" : "auto", @@ -82,49 +85,16 @@ export function PreviewPanel() { maxHeight: "100%", }} > - {renderPreviewContent()} - - {/* Playback Controls Overlay */} - {firstMediaItem && ( -
-
- - - {firstClip?.name || "No clip selected"} - -
-
- )} + {renderContent()}
- {/* Preview Info */} {firstMediaItem && (

- Preview: {firstMediaItem.name} - {firstMediaItem.type === "image" && - " (with CapCut-style treatment)"} -
- - Aspect Ratio: {aspectRatio.toFixed(2)} ( - {aspectRatio > 1 - ? "Landscape" - : aspectRatio < 1 - ? "Portrait" - : "Square"} - ) - + {firstMediaItem.name} +

+

+ {aspectRatio.toFixed(2)} • {aspectRatio > 1 ? "Landscape" : aspectRatio < 1 ? "Portrait" : "Square"}

)} diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 6b507b7..f089cb7 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -20,6 +20,7 @@ import { import { DragOverlay } from "../ui/drag-overlay"; import { useTimelineStore, type TimelineTrack } from "@/stores/timeline-store"; import { useMediaStore } from "@/stores/media-store"; +import { usePlaybackStore } from "@/stores/playback-store"; import { processMediaFiles } from "@/lib/media-processing"; import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment"; import { toast } from "sonner"; @@ -28,9 +29,12 @@ import { useState, useRef } from "react"; export function Timeline() { const { tracks, addTrack, addClipToTrack } = useTimelineStore(); const { mediaItems, addMediaItem } = useMediaStore(); + const { currentTime, duration, seek } = usePlaybackStore(); const [isDragOver, setIsDragOver] = useState(false); const [isProcessing, setIsProcessing] = useState(false); + const [zoomLevel, setZoomLevel] = useState(1); const dragCounterRef = useRef(0); + const timelineRef = useRef(null); const handleDragEnter = (e: React.DragEvent) => { e.preventDefault(); @@ -171,6 +175,25 @@ export function Timeline() { } }; + const handleTimelineClick = (e: React.MouseEvent) => { + const timeline = timelineRef.current; + if (!timeline || duration === 0) return; + + const rect = timeline.getBoundingClientRect(); + const x = e.clientX - rect.left; + const timelineWidth = rect.width; + const visibleDuration = duration / zoomLevel; + const clickedTime = (x / timelineWidth) * visibleDuration; + + seek(Math.max(0, Math.min(duration, clickedTime))); + }; + + const handleWheel = (e: React.WheelEvent) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.05 : 0.05; + setZoomLevel(prev => Math.max(0.1, Math.min(10, prev + delta))); + }; + const dragProps = { onDragEnter: handleDragEnter, onDragOver: handleDragOver, @@ -264,46 +287,73 @@ export function Timeline() { {/* Tracks Area */} -
- {/* Time Markers */} -
- {Array.from({ length: 16 }).map((_, i) => ( +
+ {/* Timeline Header */} +
+ {/* Playhead */} + {duration > 0 && (
- {i}s +
- ))} + )} + + {/* Zoom indicator */} +
+ {zoomLevel.toFixed(1)}x +
{/* Timeline Tracks */} - {tracks.length === 0 ? ( -
-
- +
+ {tracks.length === 0 ? ( +
+
+ +
+

+ No tracks in timeline +

+

+ Add a video or audio track to get started +

-

- No tracks in timeline -

-

- Add a video or audio track to get started -

-
- ) : ( -
- {tracks.map((track) => ( - - ))} -
- )} + ) : ( +
+ {tracks.map((track) => ( + + ))} +
+ )} + + {/* Playhead for tracks area */} + {tracks.length > 0 && duration > 0 && ( +
+ )} +
); } -function TimelineTrackComponent({ track }: { track: TimelineTrack }) { +function TimelineTrackComponent({ track, zoomLevel }: { track: TimelineTrack, zoomLevel: number }) { const { mediaItems } = useMediaStore(); const { moveClipToTrack, reorderClipInTrack } = useTimelineStore(); const [isDropping, setIsDropping] = useState(false); @@ -528,7 +578,7 @@ function TimelineTrackComponent({ track }: { track: TimelineTrack }) { key={clip.id} className={`timeline-clip h-full rounded-sm border cursor-grab active:cursor-grabbing transition-colors ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden`} style={{ - width: `${Math.max(80, clip.duration * 50)}px`, + width: `${Math.max(80, clip.duration * 50 * zoomLevel)}px`, }} draggable={true} onDragStart={(e) => handleClipDragStart(e, clip)} diff --git a/apps/web/src/components/ui/video-player.tsx b/apps/web/src/components/ui/video-player.tsx new file mode 100644 index 0000000..127cc5d --- /dev/null +++ b/apps/web/src/components/ui/video-player.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useRef, useEffect } from "react"; +import { Button } from "./button"; +import { Play, Pause, Volume2 } from "lucide-react"; +import { usePlaybackStore } from "@/stores/playback-store"; + +interface VideoPlayerProps { + src: string; + poster?: string; + className?: string; +} + +export function VideoPlayer({ src, poster, className = "" }: VideoPlayerProps) { + const videoRef = useRef(null); + const { isPlaying, currentTime, volume, play, pause, setVolume, setDuration, setCurrentTime } = usePlaybackStore(); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + const handleTimeUpdate = () => { + setCurrentTime(video.currentTime); + }; + + const handleLoadedMetadata = () => { + setDuration(video.duration); + }; + + const handleSeekEvent = (e: CustomEvent) => { + video.currentTime = e.detail.time; + }; + + video.addEventListener("timeupdate", handleTimeUpdate); + video.addEventListener("loadedmetadata", handleLoadedMetadata); + window.addEventListener("playback-seek", handleSeekEvent as EventListener); + + return () => { + video.removeEventListener("timeupdate", handleTimeUpdate); + video.removeEventListener("loadedmetadata", handleLoadedMetadata); + window.removeEventListener("playback-seek", handleSeekEvent as EventListener); + }; + }, [setCurrentTime, setDuration]); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + if (isPlaying) { + video.play().catch(console.error); + } else { + video.pause(); + } + }, [isPlaying]); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + video.volume = volume; + }, [volume]); + + const handleSeek = (e: React.MouseEvent) => { + const video = videoRef.current; + if (!video) return; + + const rect = video.getBoundingClientRect(); + const x = e.clientX - rect.left; + const percentage = x / rect.width; + const newTime = percentage * video.duration; + + video.currentTime = newTime; + setCurrentTime(newTime); + }; + + return ( +
+