summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/app/api/health/route.ts5
-rw-r--r--apps/web/src/app/layout.tsx29
-rw-r--r--apps/web/src/components/editor-header.tsx17
-rw-r--r--apps/web/src/components/editor/audio-waveform.tsx115
-rw-r--r--apps/web/src/components/editor/media-panel.tsx72
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx67
-rw-r--r--apps/web/src/components/editor/project-name-editor.tsx110
-rw-r--r--apps/web/src/components/editor/timeline.tsx97
-rw-r--r--apps/web/src/components/ui/video-player.tsx196
-rw-r--r--apps/web/src/stores/playback-store.ts67
-rw-r--r--apps/web/src/stores/project-store.ts13
-rw-r--r--apps/web/src/types/playback.ts7
12 files changed, 602 insertions, 193 deletions
diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts
new file mode 100644
index 00000000..ecb9ce04
--- /dev/null
+++ b/apps/web/src/app/api/health/route.ts
@@ -0,0 +1,5 @@
+import { NextRequest } from "next/server";
+
+export async function GET(request: NextRequest) {
+ return new Response("OK", { status: 200 });
+}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 1cd4b3b9..5d33100d 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -16,6 +16,35 @@ export const metadata: Metadata = {
title: "OpenCut",
description:
"A simple but powerful video editor that gets the job done. In your browser.",
+ openGraph: {
+ title: "OpenCut",
+ description:
+ "A simple but powerful video editor that gets the job done. In your browser.",
+ url: "https://opencut.app",
+ siteName: "OpenCut",
+ locale: "en_US",
+ type: "website",
+ images: [
+ {
+ url: "https://opencut.app/opengraph-image.jpg",
+ width: 1200,
+ height: 630,
+ alt: "OpenCut",
+ },
+ ],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: "OpenCut",
+ description:
+ "A simple but powerful video editor that gets the job done. In your browser.",
+ creator: "@opencutapp",
+ images: ["/opengraph-image.jpg"],
+ },
+ robots: {
+ index: true,
+ follow: true,
+ },
};
export default function RootLayout({
diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx
index c6cecbe8..6f9f0b2f 100644
--- a/apps/web/src/components/editor-header.tsx
+++ b/apps/web/src/components/editor-header.tsx
@@ -6,6 +6,7 @@ import { ChevronLeft, Download } from "lucide-react";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { HeaderBase } from "./header-base";
+import { ProjectNameEditor } from "./editor/project-name-editor";
export function EditorHeader() {
const { activeProject } = useProjectStore();
@@ -24,13 +25,15 @@ export function EditorHeader() {
};
const leftContent = (
- <Link
- href="/"
- className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
- >
- <ChevronLeft className="h-4 w-4" />
- <span className="text-sm">{activeProject?.name || "Loading..."}</span>
- </Link>
+ <div className="flex items-center gap-2">
+ <Link
+ href="/"
+ className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
+ >
+ <ChevronLeft className="h-4 w-4" />
+ </Link>
+ <ProjectNameEditor />
+ </div>
);
const centerContent = (
diff --git a/apps/web/src/components/editor/audio-waveform.tsx b/apps/web/src/components/editor/audio-waveform.tsx
new file mode 100644
index 00000000..75a5ae06
--- /dev/null
+++ b/apps/web/src/components/editor/audio-waveform.tsx
@@ -0,0 +1,115 @@
+import React, { useEffect, useRef, useState } from 'react';
+import WaveSurfer from 'wavesurfer.js';
+
+interface AudioWaveformProps {
+ audioUrl: string;
+ height?: number;
+ className?: string;
+}
+
+const AudioWaveform: React.FC<AudioWaveformProps> = ({
+ audioUrl,
+ height = 32,
+ className = ''
+}) => {
+ const waveformRef = useRef<HTMLDivElement>(null);
+ const wavesurfer = useRef<WaveSurfer | null>(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(false);
+
+ useEffect(() => {
+ let mounted = true;
+
+ const initWaveSurfer = async () => {
+ if (!waveformRef.current || !audioUrl) return;
+
+ try {
+ // Clean up any existing instance
+ if (wavesurfer.current) {
+ try {
+ wavesurfer.current.destroy();
+ } catch (e) {
+ // Silently ignore destroy errors
+ }
+ wavesurfer.current = null;
+ }
+
+ wavesurfer.current = WaveSurfer.create({
+ container: waveformRef.current,
+ waveColor: 'rgba(255, 255, 255, 0.6)',
+ progressColor: 'rgba(255, 255, 255, 0.9)',
+ cursorColor: 'transparent',
+ barWidth: 2,
+ barGap: 1,
+ height: height,
+ normalize: true,
+ interact: false,
+ });
+
+ // Event listeners
+ wavesurfer.current.on('ready', () => {
+ if (mounted) {
+ setIsLoading(false);
+ setError(false);
+ }
+ });
+
+ wavesurfer.current.on('error', (err) => {
+ console.error('WaveSurfer error:', err);
+ if (mounted) {
+ setError(true);
+ setIsLoading(false);
+ }
+ });
+
+ await wavesurfer.current.load(audioUrl);
+
+ } catch (err) {
+ console.error('Failed to initialize WaveSurfer:', err);
+ if (mounted) {
+ setError(true);
+ setIsLoading(false);
+ }
+ }
+ };
+
+ initWaveSurfer();
+
+ return () => {
+ mounted = false;
+ if (wavesurfer.current) {
+ try {
+ wavesurfer.current.destroy();
+ } catch (e) {
+ // Silently ignore destroy errors
+ }
+ wavesurfer.current = null;
+ }
+ };
+ }, [audioUrl, height]);
+
+ if (error) {
+ return (
+ <div className={`flex items-center justify-center ${className}`} style={{ height }}>
+ <span className="text-xs text-foreground/60">Audio unavailable</span>
+ </div>
+ );
+ }
+
+ return (
+ <div className={`relative ${className}`}>
+ {isLoading && (
+ <div className="absolute inset-0 flex items-center justify-center">
+ <span className="text-xs text-foreground/60">Loading...</span>
+ </div>
+ )}
+ <div
+ ref={waveformRef}
+ className={`w-full transition-opacity duration-200 ${isLoading ? 'opacity-0' : 'opacity-100'}`}
+ style={{ height }}
+ />
+ </div>
+ );
+};
+
+export default AudioWaveform; \ No newline at end of file
diff --git a/apps/web/src/components/editor/media-panel.tsx b/apps/web/src/components/editor/media-panel.tsx
index 95cfa0a8..f2b3f657 100644
--- a/apps/web/src/components/editor/media-panel.tsx
+++ b/apps/web/src/components/editor/media-panel.tsx
@@ -84,17 +84,20 @@ export function MediaPanel() {
useEffect(() => {
const filtered = mediaItems.filter((item) => {
- if (mediaFilter && mediaFilter !== 'all' && item.type !== mediaFilter) {
+ if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
return false;
}
-
- if (searchQuery && !item.name.toLowerCase().includes(searchQuery.toLowerCase())) {
+
+ if (
+ searchQuery &&
+ !item.name.toLowerCase().includes(searchQuery.toLowerCase())
+ ) {
return false;
}
-
+
return true;
});
-
+
setFilteredMediaItems(filtered);
}, [mediaItems, mediaFilter, searchQuery]);
@@ -209,23 +212,23 @@ export function MediaPanel() {
{/* Button to add/upload media */}
<div className="flex gap-2">
{/* Search and filter controls */}
- <select
- value={mediaFilter}
- onChange={(e) => setMediaFilter(e.target.value)}
- className="px-2 py-1 text-xs border rounded bg-background"
- >
- <option value="all">All</option>
- <option value="video">Video</option>
- <option value="audio">Audio</option>
- <option value="image">Image</option>
- </select>
- <input
- type="text"
- placeholder="Search media..."
- className="min-w-[60px] flex-1 px-2 py-1 text-xs border rounded bg-background"
- value={searchQuery}
- onChange={(e) => setSearchQuery(e.target.value)}
- />
+ <select
+ value={mediaFilter}
+ onChange={(e) => setMediaFilter(e.target.value)}
+ className="px-2 py-1 text-xs border rounded bg-background"
+ >
+ <option value="all">All</option>
+ <option value="video">Video</option>
+ <option value="audio">Audio</option>
+ <option value="image">Image</option>
+ </select>
+ <input
+ type="text"
+ placeholder="Search media..."
+ className="min-w-[60px] flex-1 px-2 py-1 text-xs border rounded bg-background"
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ />
{/* Add media button */}
<Button
@@ -233,21 +236,26 @@ export function MediaPanel() {
size="sm"
onClick={handleFileSelect}
disabled={isProcessing}
- className="flex-none min-w-[80px] whitespace-nowrap"
+ className="flex-none min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
>
{isProcessing ? (
<>
- <Upload className="h-4 w-4 mr-2 animate-spin" />
- Processing...
+ <Upload className="h-4 w-4 animate-spin" />
+ <span className="hidden md:inline ml-2">Processing...</span>
</>
) : (
<>
<Plus className="h-4 w-4" />
- Add
+ <span
+ className="hidden sm:inline ml-2"
+ aria-label="Add file"
+ >
+ Add
+ </span>
</>
)}
</Button>
- </div>
+ </div>
</div>
<div className="flex-1 overflow-y-auto p-2">
@@ -276,7 +284,15 @@ export function MediaPanel() {
<AspectRatio ratio={item.aspectRatio}>
{renderPreview(item)}
</AspectRatio>
- <span className="text-xs truncate px-1">{item.name}</span>
+ <span
+ className="text-xs truncate px-1 max-w-full"
+ aria-label={item.name}
+ title={item.name}
+ >
+ {item.name.length > 8
+ ? `${item.name.slice(0, 4)}...${item.name.slice(-3)}`
+ : item.name}
+ </span>
</Button>
{/* Show remove button on hover */}
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index 624a549e..33bfe688 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -5,16 +5,17 @@ import { useMediaStore } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { VideoPlayer } from "@/components/ui/video-player";
import { Button } from "@/components/ui/button";
-import { Play, Pause } from "lucide-react";
+import { Play, Pause, Volume2, VolumeX } from "lucide-react";
import { useState, useRef } from "react";
// Debug flag - set to false to hide active clips info
-const SHOW_DEBUG_INFO = process.env.NODE_ENV === 'development';
+const SHOW_DEBUG_INFO = process.env.NODE_ENV === "development";
export function PreviewPanel() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
- const { isPlaying, toggle, currentTime } = usePlaybackStore();
+ const { isPlaying, toggle, currentTime, muted, toggleMute, volume } =
+ usePlaybackStore();
const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
const [showDebug, setShowDebug] = useState(SHOW_DEBUG_INFO);
const previewRef = useRef<HTMLDivElement>(null);
@@ -30,12 +31,14 @@ export function PreviewPanel() {
tracks.forEach((track) => {
track.clips.forEach((clip) => {
const clipStart = clip.startTime;
- const clipEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+ const clipEnd =
+ clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (currentTime >= clipStart && currentTime < clipEnd) {
- const mediaItem = clip.mediaId === "test"
- ? { type: "test", name: clip.name, url: "", thumbnailUrl: "" }
- : mediaItems.find((item) => item.id === clip.mediaId);
+ const mediaItem =
+ clip.mediaId === "test"
+ ? { type: "test", name: clip.name, url: "", thumbnailUrl: "" }
+ : mediaItems.find((item) => item.id === clip.mediaId);
if (mediaItem || clip.mediaId === "test") {
activeClips.push({ clip, track, mediaItem });
@@ -134,13 +137,19 @@ export function PreviewPanel() {
<select
value={`${canvasSize.width}x${canvasSize.height}`}
onChange={(e) => {
- const preset = canvasPresets.find(p => `${p.width}x${p.height}` === e.target.value);
- if (preset) setCanvasSize({ width: preset.width, height: preset.height });
+ const preset = canvasPresets.find(
+ (p) => `${p.width}x${p.height}` === e.target.value
+ );
+ if (preset)
+ setCanvasSize({ width: preset.width, height: preset.height });
}}
className="bg-background border rounded px-2 py-1 text-xs"
>
- {canvasPresets.map(preset => (
- <option key={preset.name} value={`${preset.width}x${preset.height}`}>
+ {canvasPresets.map((preset) => (
+ <option
+ key={preset.name}
+ value={`${preset.width}x${preset.height}`}
+ >
{preset.name} ({preset.width}×{preset.height})
</option>
))}
@@ -154,12 +163,30 @@ export function PreviewPanel() {
onClick={() => setShowDebug(!showDebug)}
className="text-xs"
>
- Debug {showDebug ? 'ON' : 'OFF'}
+ Debug {showDebug ? "ON" : "OFF"}
</Button>
)}
- <Button variant="outline" size="sm" onClick={toggle} className="ml-auto">
- {isPlaying ? <Pause className="h-3 w-3 mr-1" /> : <Play className="h-3 w-3 mr-1" />}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={toggleMute}
+ className="ml-auto"
+ >
+ {muted || volume === 0 ? (
+ <VolumeX className="h-3 w-3 mr-1" />
+ ) : (
+ <Volume2 className="h-3 w-3 mr-1" />
+ )}
+ {muted || volume === 0 ? "Unmute" : "Mute"}
+ </Button>
+
+ <Button variant="outline" size="sm" onClick={toggle}>
+ {isPlaying ? (
+ <Pause className="h-3 w-3 mr-1" />
+ ) : (
+ <Play className="h-3 w-3 mr-1" />
+ )}
{isPlaying ? "Pause" : "Play"}
</Button>
</div>
@@ -177,7 +204,9 @@ export function PreviewPanel() {
>
{activeClips.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-white/50">
- {tracks.length === 0 ? "Drop media to start editing" : "No clips at current time"}
+ {tracks.length === 0
+ ? "Drop media to start editing"
+ : "No clips at current time"}
</div>
) : (
activeClips.map((clipData, index) => renderClip(clipData, index))
@@ -188,7 +217,9 @@ export function PreviewPanel() {
{/* Debug Info Panel - Conditionally rendered */}
{showDebug && (
<div className="border-t bg-background p-2 flex-shrink-0">
- <div className="text-xs font-medium mb-1">Debug: Active Clips ({activeClips.length})</div>
+ <div className="text-xs font-medium mb-1">
+ Debug: Active Clips ({activeClips.length})
+ </div>
<div className="flex gap-2 overflow-x-auto">
{activeClips.map((clipData, index) => (
<div
@@ -199,7 +230,9 @@ export function PreviewPanel() {
{index + 1}
</span>
<span>{clipData.clip.name}</span>
- <span className="text-muted-foreground">({clipData.mediaItem?.type || 'test'})</span>
+ <span className="text-muted-foreground">
+ ({clipData.mediaItem?.type || "test"})
+ </span>
</div>
))}
{activeClips.length === 0 && (
diff --git a/apps/web/src/components/editor/project-name-editor.tsx b/apps/web/src/components/editor/project-name-editor.tsx
new file mode 100644
index 00000000..aa0141ae
--- /dev/null
+++ b/apps/web/src/components/editor/project-name-editor.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import { useState, useRef, useEffect } from "react";
+import { Input } from "../ui/input";
+import { useProjectStore } from "@/stores/project-store";
+import { Edit2, Check, X } from "lucide-react";
+import { Button } from "../ui/button";
+
+interface ProjectNameEditorProps {
+ className?: string;
+}
+
+export function ProjectNameEditor({ className }: ProjectNameEditorProps) {
+ const { activeProject, updateProjectName } = useProjectStore();
+ const [isEditing, setIsEditing] = useState(false);
+ const [editValue, setEditValue] = useState("");
+ const inputRef = useRef<HTMLInputElement>(null);
+
+ useEffect(() => {
+ if (activeProject) {
+ setEditValue(activeProject.name);
+ }
+ }, [activeProject]);
+
+ useEffect(() => {
+ if (isEditing && inputRef.current) {
+ inputRef.current.focus();
+ inputRef.current.select();
+ }
+ }, [isEditing]);
+
+ const handleStartEdit = () => {
+ if (activeProject) {
+ setEditValue(activeProject.name);
+ setIsEditing(true);
+ }
+ };
+
+ const handleSave = () => {
+ if (editValue.trim()) {
+ updateProjectName(editValue.trim());
+ setIsEditing(false);
+ }
+ };
+
+ const handleCancel = () => {
+ if (activeProject) {
+ setEditValue(activeProject.name);
+ }
+ setIsEditing(false);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ handleSave();
+ } else if (e.key === "Escape") {
+ handleCancel();
+ }
+ };
+
+ if (!activeProject) {
+ return <span className="text-sm text-muted-foreground">Loading...</span>;
+ }
+
+ if (isEditing) {
+ return (
+ <div className="flex items-center gap-1">
+ <Input
+ ref={inputRef}
+ value={editValue}
+ onChange={(e) => setEditValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ className="h-7 text-sm px-3 py-1 min-w-[200px]"
+ size={1}
+ />
+ <Button
+ size="sm"
+ variant="text"
+ onClick={handleSave}
+ className="h-7 w-7 p-0"
+ disabled={!editValue.trim()}
+ >
+ <Check className="h-3 w-3" />
+ </Button>
+ <Button
+ size="sm"
+ variant="text"
+ onClick={handleCancel}
+ className="h-7 w-7 p-0"
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ );
+ }
+
+ return (
+ <div className="flex items-center gap-1 group">
+ <span className="text-sm font-medium">{activeProject.name}</span>
+ <Button
+ size="sm"
+ variant="text"
+ onClick={handleStartEdit}
+ className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
+ >
+ <Edit2 className="h-3 w-3" />
+ </Button>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index 12907738..c1b8f3e8 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -35,6 +35,7 @@ import {
SelectTrigger,
SelectValue,
} from "../ui/select";
+import AudioWaveform from "./audio-waveform";
export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their clips.
@@ -221,7 +222,7 @@ export function Timeline() {
const clipLeft = clip.startTime * 50 * zoomLevel;
const clipTop = trackIdx * 60;
const clipBottom = clipTop + 60;
- const clipRight = clipLeft + clipWidth;
+ const clipRight = clipLeft + 60; // Set a fixed width for time display
if (
bx1 < clipRight &&
bx2 > clipLeft &&
@@ -566,15 +567,6 @@ export function Timeline() {
onMouseLeave={() => setIsInTimeline(false)}
onWheel={handleWheel}
>
- {/* Show overlay when dragging media over the timeline */}
- {isDragOver && (
- <div className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none backdrop-blur-lg">
- <div>
- Drop media here to add to timeline
- </div>
- </div>
- )}
-
{/* Toolbar */}
<div className="border-b flex items-center px-2 py-1 gap-1">
<TooltipProvider delayDuration={500}>
@@ -602,38 +594,40 @@ export function Timeline() {
<div className="w-px h-6 bg-border mx-1" />
{/* Time Display */}
- <div className="text-xs text-muted-foreground font-mono px-2">
- {Math.floor(currentTime * 10) / 10}s /{" "}
- {Math.floor(duration * 10) / 10}s
+ <div className="text-xs text-muted-foreground font-mono px-2"
+ style={{ minWidth: '18ch', textAlign: 'center' }}
+ >
+ {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
</div>
- <div className="w-px h-6 bg-border mx-1" />
-
{/* Test Clip Button - for debugging */}
{tracks.length === 0 && (
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- onClick={() => {
- const trackId = addTrack("video");
- addClipToTrack(trackId, {
- mediaId: "test",
- name: "Test Clip",
- duration: 5,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- });
- }}
- className="text-xs"
- >
- Add Test Clip
- </Button>
- </TooltipTrigger>
- <TooltipContent>Add a test clip to try playback</TooltipContent>
- </Tooltip>
+ <>
+ <div className="w-px h-6 bg-border mx-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const trackId = addTrack("video");
+ addClipToTrack(trackId, {
+ mediaId: "test",
+ name: "Test Clip",
+ duration: 5,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ }}
+ className="text-xs"
+ >
+ Add Test Clip
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Add a test clip to try playback</TooltipContent>
+ </Tooltip>
+ </>
)}
<div className="w-px h-6 bg-border mx-1" />
@@ -951,6 +945,17 @@ export function Timeline() {
)}
</>
)}
+ {isDragOver && (
+ <div
+ className="absolute left-0 right-0 border-2 border-dashed border-accent flex items-center justify-center text-muted-foreground"
+ style={{
+ top: `${tracks.length * 60}px`,
+ height: "60px",
+ }}
+ >
+ <div>Drop media here to add a new track</div>
+ </div>
+ )}
</div>
</div>
</div>
@@ -1624,7 +1629,21 @@ function TimelineTrackContent({
);
}
- // Fallback for audio or videos without thumbnails
+ if (mediaItem.type === "audio") {
+ return (
+ <div className="w-full h-full flex items-center gap-2">
+ <div className="flex-1 min-w-0">
+ <AudioWaveform
+ audioUrl={mediaItem.url}
+ height={24}
+ className="w-full"
+ />
+ </div>
+ </div>
+ );
+ }
+
+ // Fallback for videos without thumbnails
return (
<span className="text-xs text-foreground/80 truncate">{clip.name}</span>
);
diff --git a/apps/web/src/components/ui/video-player.tsx b/apps/web/src/components/ui/video-player.tsx
index 32a12916..d8a72e40 100644
--- a/apps/web/src/components/ui/video-player.tsx
+++ b/apps/web/src/components/ui/video-player.tsx
@@ -4,108 +4,128 @@ import { useRef, useEffect } from "react";
import { usePlaybackStore } from "@/stores/playback-store";
interface VideoPlayerProps {
- src: string;
- poster?: string;
- className?: string;
- clipStartTime: number;
- trimStart: number;
- trimEnd: number;
- clipDuration: number;
+ src: string;
+ poster?: string;
+ className?: string;
+ clipStartTime: number;
+ trimStart: number;
+ trimEnd: number;
+ clipDuration: number;
}
export function VideoPlayer({
- src,
- poster,
- className = "",
- clipStartTime,
- trimStart,
- trimEnd,
- clipDuration
+ src,
+ poster,
+ className = "",
+ clipStartTime,
+ trimStart,
+ trimEnd,
+ clipDuration,
}: VideoPlayerProps) {
- const videoRef = useRef<HTMLVideoElement>(null);
- const { isPlaying, currentTime, volume, speed } = usePlaybackStore();
+ const videoRef = useRef<HTMLVideoElement>(null);
+ const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
- // Calculate if we're within this clip's timeline range
- const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
- const isInClipRange = currentTime >= clipStartTime && currentTime < clipEndTime;
+ // Calculate if we're within this clip's timeline range
+ const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
+ const isInClipRange =
+ currentTime >= clipStartTime && currentTime < clipEndTime;
- // Sync playback events
- useEffect(() => {
- const video = videoRef.current;
- if (!video || !isInClipRange) return;
+ // Sync playback events
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video || !isInClipRange) return;
- const handleSeekEvent = (e: CustomEvent) => {
- // Always update video time, even if outside clip range
- const timelineTime = e.detail.time;
- const videoTime = Math.max(trimStart, Math.min(
- clipDuration - trimEnd,
- timelineTime - clipStartTime + trimStart
- ));
- video.currentTime = videoTime;
- };
+ const handleSeekEvent = (e: CustomEvent) => {
+ // Always update video time, even if outside clip range
+ const timelineTime = e.detail.time;
+ const videoTime = Math.max(
+ trimStart,
+ Math.min(
+ clipDuration - trimEnd,
+ timelineTime - clipStartTime + trimStart
+ )
+ );
+ video.currentTime = videoTime;
+ };
- const handleUpdateEvent = (e: CustomEvent) => {
- // Always update video time, even if outside clip range
- const timelineTime = e.detail.time;
- const targetTime = Math.max(trimStart, Math.min(
- clipDuration - trimEnd,
- timelineTime - clipStartTime + trimStart
- ));
+ const handleUpdateEvent = (e: CustomEvent) => {
+ // Always update video time, even if outside clip range
+ const timelineTime = e.detail.time;
+ const targetTime = Math.max(
+ trimStart,
+ Math.min(
+ clipDuration - trimEnd,
+ timelineTime - clipStartTime + trimStart
+ )
+ );
- if (Math.abs(video.currentTime - targetTime) > 0.5) {
- video.currentTime = targetTime;
- }
- };
+ if (Math.abs(video.currentTime - targetTime) > 0.5) {
+ video.currentTime = targetTime;
+ }
+ };
- const handleSpeed = (e: CustomEvent) => {
- video.playbackRate = e.detail.speed;
- };
+ const handleSpeed = (e: CustomEvent) => {
+ video.playbackRate = e.detail.speed;
+ };
- window.addEventListener("playback-seek", handleSeekEvent as EventListener);
- window.addEventListener("playback-update", handleUpdateEvent as EventListener);
- window.addEventListener("playback-speed", handleSpeed as EventListener);
+ window.addEventListener("playback-seek", handleSeekEvent as EventListener);
+ window.addEventListener(
+ "playback-update",
+ handleUpdateEvent as EventListener
+ );
+ window.addEventListener("playback-speed", handleSpeed as EventListener);
- return () => {
- window.removeEventListener("playback-seek", handleSeekEvent as EventListener);
- window.removeEventListener("playback-update", handleUpdateEvent as EventListener);
- window.removeEventListener("playback-speed", handleSpeed as EventListener);
- };
- }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
+ return () => {
+ window.removeEventListener(
+ "playback-seek",
+ handleSeekEvent as EventListener
+ );
+ window.removeEventListener(
+ "playback-update",
+ handleUpdateEvent as EventListener
+ );
+ window.removeEventListener(
+ "playback-speed",
+ handleSpeed as EventListener
+ );
+ };
+ }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
- // Sync playback state
- useEffect(() => {
- const video = videoRef.current;
- if (!video) return;
+ // Sync playback state
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
- if (isPlaying && isInClipRange) {
- video.play().catch(() => { });
- } else {
- video.pause();
- }
- }, [isPlaying, isInClipRange]);
+ if (isPlaying && isInClipRange) {
+ video.play().catch(() => {});
+ } else {
+ video.pause();
+ }
+ }, [isPlaying, isInClipRange]);
- // Sync volume and speed
- useEffect(() => {
- const video = videoRef.current;
- if (!video) return;
+ // Sync volume and speed
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
- video.volume = volume;
- video.playbackRate = speed;
- }, [volume, speed]);
+ video.volume = volume;
+ video.muted = muted;
+ video.playbackRate = speed;
+ }, [volume, speed, muted]);
- return (
- <video
- ref={videoRef}
- src={src}
- poster={poster}
- className={`w-full h-full object-cover ${className}`}
- playsInline
- preload="auto"
- controls={false}
- disablePictureInPicture
- disableRemotePlayback
- style={{ pointerEvents: 'none' }}
- onContextMenu={(e) => e.preventDefault()}
- />
- );
-} \ No newline at end of file
+ return (
+ <video
+ ref={videoRef}
+ src={src}
+ poster={poster}
+ className={`w-full h-full object-cover ${className}`}
+ playsInline
+ preload="auto"
+ controls={false}
+ disablePictureInPicture
+ disableRemotePlayback
+ style={{ pointerEvents: "none" }}
+ onContextMenu={(e) => e.preventDefault()}
+ />
+ );
+}
diff --git a/apps/web/src/stores/playback-store.ts b/apps/web/src/stores/playback-store.ts
index a4794b7f..0cc01cc1 100644
--- a/apps/web/src/stores/playback-store.ts
+++ b/apps/web/src/stores/playback-store.ts
@@ -10,7 +10,7 @@ let playbackTimer: number | null = null;
const startTimer = (store: any) => {
if (playbackTimer) cancelAnimationFrame(playbackTimer);
-
+
// Use requestAnimationFrame for smoother updates
const updateTime = () => {
const state = store();
@@ -18,14 +18,22 @@ const startTimer = (store: any) => {
const now = performance.now();
const delta = (now - lastUpdate) / 1000; // Convert to seconds
lastUpdate = now;
-
- const newTime = state.currentTime + (delta * state.speed);
+
+ const newTime = state.currentTime + delta * state.speed;
if (newTime >= state.duration) {
+ // When video completes, pause and reset playhead to start
state.pause();
+ state.setCurrentTime(0);
+ // Notify video elements to sync with reset
+ window.dispatchEvent(
+ new CustomEvent("playback-seek", { detail: { time: 0 } })
+ );
} else {
state.setCurrentTime(newTime);
// Notify video elements to sync
- window.dispatchEvent(new CustomEvent('playback-update', { detail: { time: newTime } }));
+ window.dispatchEvent(
+ new CustomEvent("playback-update", { detail: { time: newTime } })
+ );
}
}
playbackTimer = requestAnimationFrame(updateTime);
@@ -47,6 +55,8 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
currentTime: 0,
duration: 0,
volume: 1,
+ muted: false,
+ previousVolume: 1,
speed: 1.0,
play: () => {
@@ -72,22 +82,53 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
const { duration } = get();
const clampedTime = Math.max(0, Math.min(duration, time));
set({ currentTime: clampedTime });
-
- // Notify video elements to seek
- const event = new CustomEvent('playback-seek', { detail: { time: clampedTime } });
+
+ const event = new CustomEvent("playback-seek", {
+ detail: { time: clampedTime },
+ });
window.dispatchEvent(event);
},
-
- setVolume: (volume: number) => set({ volume: Math.max(0, Math.min(1, volume)) }),
-
+
+ setVolume: (volume: number) =>
+ set((state) => ({
+ volume: Math.max(0, Math.min(1, volume)),
+ muted: volume === 0,
+ previousVolume: volume > 0 ? volume : state.previousVolume,
+ })),
+
setSpeed: (speed: number) => {
const newSpeed = Math.max(0.1, Math.min(2.0, speed));
set({ speed: newSpeed });
-
- const event = new CustomEvent('playback-speed', { detail: { speed: newSpeed } });
+
+ const event = new CustomEvent("playback-speed", {
+ detail: { speed: newSpeed },
+ });
window.dispatchEvent(event);
},
setDuration: (duration: number) => set({ duration }),
setCurrentTime: (time: number) => set({ currentTime: time }),
-})); \ No newline at end of file
+
+ mute: () => {
+ const { volume, previousVolume } = get();
+ set({
+ muted: true,
+ previousVolume: volume > 0 ? volume : previousVolume,
+ volume: 0,
+ });
+ },
+
+ unmute: () => {
+ const { previousVolume } = get();
+ set({ muted: false, volume: previousVolume ?? 1 });
+ },
+
+ toggleMute: () => {
+ const { muted } = get();
+ if (muted) {
+ get().unmute();
+ } else {
+ get().mute();
+ }
+ },
+}));
diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts
index 734c07e2..0d0812cd 100644
--- a/apps/web/src/stores/project-store.ts
+++ b/apps/web/src/stores/project-store.ts
@@ -7,6 +7,7 @@ interface ProjectStore {
// Actions
createNewProject: (name: string) => void;
closeProject: () => void;
+ updateProjectName: (name: string) => void;
}
export const useProjectStore = create<ProjectStore>((set) => ({
@@ -25,4 +26,16 @@ export const useProjectStore = create<ProjectStore>((set) => ({
closeProject: () => {
set({ activeProject: null });
},
+
+ updateProjectName: (name: string) => {
+ set((state) => ({
+ activeProject: state.activeProject
+ ? {
+ ...state.activeProject,
+ name,
+ updatedAt: new Date(),
+ }
+ : null,
+ }));
+ },
}));
diff --git a/apps/web/src/types/playback.ts b/apps/web/src/types/playback.ts
index 3fbd1a02..8758ea03 100644
--- a/apps/web/src/types/playback.ts
+++ b/apps/web/src/types/playback.ts
@@ -4,6 +4,8 @@ export interface PlaybackState {
duration: number;
volume: number;
speed: number;
+ muted: boolean;
+ previousVolume?: number;
}
export interface PlaybackControls {
@@ -13,4 +15,7 @@ export interface PlaybackControls {
setVolume: (volume: number) => void;
setSpeed: (speed: number) => void;
toggle: () => void;
-} \ No newline at end of file
+ mute: () => void;
+ unmute: () => void;
+ toggleMute: () => void;
+}