diff options
| author | Hyteq <DerpsDer.es@gmail.com> | 2025-06-23 09:18:15 +0300 |
|---|---|---|
| committer | Hyteq <DerpsDer.es@gmail.com> | 2025-06-23 09:18:15 +0300 |
| commit | ed6ab6cd5b6fec5f8e7d7cbcaa5224393729164e (patch) | |
| tree | c9df67eeec551f40737b1b26ab7e992c8b55c3e2 /apps/web | |
| parent | 589f4a20a170c221203f7515622c38900e2efde4 (diff) | |
| download | opencut-ed6ab6cd5b6fec5f8e7d7cbcaa5224393729164e.tar.gz | |
feat: cleaned up video player, added functionality, zooming in/out, timeline position viewer, keyboard controls
Diffstat (limited to 'apps/web')
| -rw-r--r-- | apps/web/bun.lock | 4 | ||||
| -rw-r--r-- | apps/web/package.json | 2 | ||||
| -rw-r--r-- | apps/web/src/app/editor/page.tsx | 4 | ||||
| -rw-r--r-- | apps/web/src/components/editor/preview-panel.tsx | 92 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline.tsx | 106 | ||||
| -rw-r--r-- | apps/web/src/components/ui/video-player.tsx | 117 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-playback-controls.ts | 18 | ||||
| -rw-r--r-- | apps/web/src/middleware.ts | 2 | ||||
| -rw-r--r-- | apps/web/src/stores/playback-store.ts | 30 | ||||
| -rw-r--r-- | apps/web/src/types/playback.ts | 14 |
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 |
