summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
authorHyteq <DerpsDer.es@gmail.com>2025-06-23 09:18:15 +0300
committerHyteq <DerpsDer.es@gmail.com>2025-06-23 09:18:15 +0300
commited6ab6cd5b6fec5f8e7d7cbcaa5224393729164e (patch)
treec9df67eeec551f40737b1b26ab7e992c8b55c3e2 /apps
parent589f4a20a170c221203f7515622c38900e2efde4 (diff)
downloadopencut-ed6ab6cd5b6fec5f8e7d7cbcaa5224393729164e.tar.gz
feat: cleaned up video player, added functionality, zooming in/out, timeline position viewer, keyboard controls
Diffstat (limited to 'apps')
-rw-r--r--apps/web/bun.lock4
-rw-r--r--apps/web/package.json2
-rw-r--r--apps/web/src/app/editor/page.tsx4
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx92
-rw-r--r--apps/web/src/components/editor/timeline.tsx106
-rw-r--r--apps/web/src/components/ui/video-player.tsx117
-rw-r--r--apps/web/src/hooks/use-playback-controls.ts18
-rw-r--r--apps/web/src/middleware.ts2
-rw-r--r--apps/web/src/stores/playback-store.ts30
-rw-r--r--apps/web/src/types/playback.ts14
10 files changed, 296 insertions, 93 deletions
diff --git a/apps/web/bun.lock b/apps/web/bun.lock
index 6603ef62..8f18f03b 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 2b15eb75..07b4219c 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 cc2db108..2f825a22 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 16c808f4..593758e1 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -2,57 +2,52 @@
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 (
- <div className="absolute inset-0 flex items-center justify-center text-muted-foreground/50 group-hover:text-muted-foreground/80 transition-colors">
- Drop media here or click to import
+ <div className="absolute inset-0 flex items-center justify-center text-muted-foreground/50">
+ Drop media to start editing
</div>
);
}
- if (firstMediaItem.type === "image") {
+ if (firstMediaItem.type === "video") {
return (
- <ImageTimelineTreatment
+ <VideoPlayer
src={firstMediaItem.url}
- alt={firstMediaItem.name}
- targetAspectRatio={aspectRatio}
- className="w-full h-full rounded-lg"
- backgroundType="blur"
+ poster={firstMediaItem.thumbnailUrl}
+ className="w-full h-full"
/>
);
}
- if (firstMediaItem.type === "video") {
- return firstMediaItem.thumbnailUrl ? (
- <img
- src={firstMediaItem.thumbnailUrl}
+ if (firstMediaItem.type === "image") {
+ return (
+ <ImageTimelineTreatment
+ src={firstMediaItem.url}
alt={firstMediaItem.name}
- className="w-full h-full object-cover rounded-lg"
+ targetAspectRatio={aspectRatio}
+ className="w-full h-full"
+ backgroundType="blur"
/>
- ) : (
- <div className="absolute inset-0 flex items-center justify-center text-muted-foreground/50">
- Video Preview
- </div>
);
}
@@ -62,6 +57,14 @@ export function PreviewPanel() {
<div className="text-center">
<div className="text-6xl mb-4">🎵</div>
<p className="text-muted-foreground">{firstMediaItem.name}</p>
+ <Button
+ variant="outline"
+ className="mt-4"
+ onClick={toggle}
+ >
+ {isPlaying ? <Pause className="h-4 w-4 mr-2" /> : <Play className="h-4 w-4 mr-2" />}
+ {isPlaying ? "Pause" : "Play"}
+ </Button>
</div>
</div>
);
@@ -73,7 +76,7 @@ export function PreviewPanel() {
return (
<div className="h-full flex flex-col items-center justify-center p-4 overflow-hidden">
<div
- className="bg-black/90 rounded-lg shadow-lg relative group overflow-hidden flex-shrink"
+ className="bg-black rounded-lg shadow-lg relative overflow-hidden flex-shrink-0"
style={{
aspectRatio: aspectRatio.toString(),
width: aspectRatio > 1 ? "100%" : "auto",
@@ -82,49 +85,16 @@ export function PreviewPanel() {
maxHeight: "100%",
}}
>
- {renderPreviewContent()}
-
- {/* Playback Controls Overlay */}
- {firstMediaItem && (
- <div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity">
- <div className="flex items-center gap-2 bg-black/80 rounded-lg px-4 py-2">
- <Button
- variant="ghost"
- size="icon"
- className="text-white hover:bg-white/20"
- onClick={() => setIsPlaying(!isPlaying)}
- >
- {isPlaying ? (
- <Pause className="h-5 w-5" />
- ) : (
- <Play className="h-5 w-5" />
- )}
- </Button>
- <span className="text-white text-sm">
- {firstClip?.name || "No clip selected"}
- </span>
- </div>
- </div>
- )}
+ {renderContent()}
</div>
- {/* Preview Info */}
{firstMediaItem && (
<div className="mt-4 text-center">
<p className="text-sm text-muted-foreground">
- Preview: {firstMediaItem.name}
- {firstMediaItem.type === "image" &&
- " (with CapCut-style treatment)"}
- <br />
- <span className="text-xs text-muted-foreground/70">
- Aspect Ratio: {aspectRatio.toFixed(2)} (
- {aspectRatio > 1
- ? "Landscape"
- : aspectRatio < 1
- ? "Portrait"
- : "Square"}
- )
- </span>
+ {firstMediaItem.name}
+ </p>
+ <p className="text-xs text-muted-foreground/70">
+ {aspectRatio.toFixed(2)} • {aspectRatio > 1 ? "Landscape" : aspectRatio < 1 ? "Portrait" : "Square"}
</p>
</div>
)}
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index 6b507b7d..f089cb73 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<HTMLDivElement>(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 */}
<ScrollArea className="flex-1">
- <div className="min-w-[800px]">
- {/* Time Markers */}
- <div className="py-2 pt-1 flex items-center">
- {Array.from({ length: 16 }).map((_, i) => (
+ <div
+ ref={timelineRef}
+ className="min-w-[800px] relative cursor-pointer select-none"
+ onClick={handleTimelineClick}
+ onWheel={handleWheel}
+ >
+ {/* Timeline Header */}
+ <div className="py-3 relative bg-muted/30 border-b">
+ {/* Playhead */}
+ {duration > 0 && (
<div
- key={i}
- className="w-[50px] flex items-end justify-center text-xs text-muted-foreground"
+ className="absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-none z-10"
+ style={{
+ left: `${(currentTime / (duration / zoomLevel)) * 100}%`,
+ transform: 'translateX(-50%)'
+ }}
>
- {i}s
+ <div className="absolute -top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-red-500 rounded-full border-2 border-white shadow-sm" />
</div>
- ))}
+ )}
+
+ {/* Zoom indicator */}
+ <div className="absolute top-1 right-2 text-xs text-muted-foreground">
+ {zoomLevel.toFixed(1)}x
+ </div>
</div>
{/* Timeline Tracks */}
- {tracks.length === 0 ? (
- <div className="flex flex-col items-center justify-center py-12 text-center">
- <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
- <SplitSquareHorizontal className="h-8 w-8 text-muted-foreground" />
+ <div className="relative">
+ {tracks.length === 0 ? (
+ <div className="flex flex-col items-center justify-center py-12 text-center">
+ <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
+ <SplitSquareHorizontal className="h-8 w-8 text-muted-foreground" />
+ </div>
+ <p className="text-sm text-muted-foreground">
+ No tracks in timeline
+ </p>
+ <p className="text-xs text-muted-foreground/70 mt-1">
+ Add a video or audio track to get started
+ </p>
</div>
- <p className="text-sm text-muted-foreground">
- No tracks in timeline
- </p>
- <p className="text-xs text-muted-foreground/70 mt-1">
- Add a video or audio track to get started
- </p>
- </div>
- ) : (
- <div className="flex flex-col gap-2.5">
- {tracks.map((track) => (
- <TimelineTrackComponent key={track.id} track={track} />
- ))}
- </div>
- )}
+ ) : (
+ <div className="flex flex-col gap-2.5">
+ {tracks.map((track) => (
+ <TimelineTrackComponent key={track.id} track={track} zoomLevel={zoomLevel} />
+ ))}
+ </div>
+ )}
+
+ {/* Playhead for tracks area */}
+ {tracks.length > 0 && duration > 0 && (
+ <div
+ className="absolute top-0 bottom-0 w-0.5 bg-red-500/80 pointer-events-none z-10"
+ style={{
+ left: `${(currentTime / (duration / zoomLevel)) * 100}%`,
+ transform: 'translateX(-50%)'
+ }}
+ />
+ )}
+ </div>
</div>
</ScrollArea>
</div>
);
}
-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 00000000..127cc5db
--- /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<HTMLVideoElement>(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<HTMLVideoElement>) => {
+ 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 (
+ <div className={`relative group ${className}`}>
+ <video
+ ref={videoRef}
+ src={src}
+ poster={poster}
+ className="w-full h-full object-cover cursor-pointer"
+ onClick={handleSeek}
+ 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>
+ );
+} \ 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 00000000..5742e272
--- /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 db02dc5c..7f096cd5 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 00000000..7db1fb93
--- /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<PlaybackStore>((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 00000000..88113ef3
--- /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