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 ? (
-

- ) : (
-
- 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 && (
- ))}
+ )}
+
+ {/* 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/hooks/use-playback-controls.ts b/apps/web/src/hooks/use-playback-controls.ts
new file mode 100644
index 0000000..5742e27
--- /dev/null
+++ b/apps/web/src/hooks/use-playback-controls.ts
@@ -0,0 +1,18 @@
+import { useEffect } from "react";
+import { usePlaybackStore } from "@/stores/playback-store";
+
+export function usePlaybackControls() {
+ const { toggle } = usePlaybackStore();
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.code === "Space" && e.target === document.body) {
+ e.preventDefault();
+ toggle();
+ }
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ return () => document.removeEventListener("keydown", handleKeyDown);
+ }, [toggle]);
+}
\ No newline at end of file
diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts
index db02dc5..7f096cd 100644
--- a/apps/web/src/middleware.ts
+++ b/apps/web/src/middleware.ts
@@ -6,7 +6,7 @@ export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname;
const session = getSessionCookie(request);
- if (path === "/editor" && !session) {
+ if (path === "/editor" && !session && process.env.NODE_ENV === "production") {
const loginUrl = new URL("/auth/login", request.url);
loginUrl.searchParams.set("redirect", request.url);
return NextResponse.redirect(loginUrl);
diff --git a/apps/web/src/stores/playback-store.ts b/apps/web/src/stores/playback-store.ts
new file mode 100644
index 0000000..7db1fb9
--- /dev/null
+++ b/apps/web/src/stores/playback-store.ts
@@ -0,0 +1,30 @@
+import { create } from "zustand";
+import type { PlaybackState, PlaybackControls } from "@/types/playback";
+
+interface PlaybackStore extends PlaybackState, PlaybackControls {
+ setDuration: (duration: number) => void;
+ setCurrentTime: (time: number) => void;
+}
+
+export const usePlaybackStore = create((set, get) => ({
+ isPlaying: false,
+ currentTime: 0,
+ duration: 0,
+ volume: 1,
+
+ play: () => set({ isPlaying: true }),
+ pause: () => set({ isPlaying: false }),
+ toggle: () => set((state) => ({ isPlaying: !state.isPlaying })),
+ seek: (time: number) => {
+ const { duration } = get();
+ const clampedTime = Math.max(0, Math.min(duration, time));
+ set({ currentTime: clampedTime });
+
+ // Notify video element to seek
+ const event = new CustomEvent('playback-seek', { detail: { time: clampedTime } });
+ window.dispatchEvent(event);
+ },
+ setVolume: (volume: number) => set({ volume: Math.max(0, Math.min(1, volume)) }),
+ setDuration: (duration: number) => set({ duration }),
+ setCurrentTime: (time: number) => set({ currentTime: time }),
+}));
\ No newline at end of file
diff --git a/apps/web/src/types/playback.ts b/apps/web/src/types/playback.ts
new file mode 100644
index 0000000..88113ef
--- /dev/null
+++ b/apps/web/src/types/playback.ts
@@ -0,0 +1,14 @@
+export interface PlaybackState {
+ isPlaying: boolean;
+ currentTime: number;
+ duration: number;
+ volume: number;
+}
+
+export interface PlaybackControls {
+ play: () => void;
+ pause: () => void;
+ seek: (time: number) => void;
+ setVolume: (volume: number) => void;
+ toggle: () => void;
+}
\ No newline at end of file