diff options
| author | Maze Winther <mazewinther@gmail.com> | 2025-06-25 16:18:00 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2025-06-25 16:18:00 +0200 |
| commit | 0ae852c185d5d3ff0bd94310bd8000de8bed5ccc (patch) | |
| tree | 008549c7b27e3bfe7a6bcf265609b36a1e8ca855 /apps/web/src/components | |
| parent | db603543494cc55372191d723f1817e9c4f4b383 (diff) | |
| parent | 067b6e3d7373d37dbc4542acf9f2ecce1dc1b9f6 (diff) | |
| download | opencut-0ae852c185d5d3ff0bd94310bd8000de8bed5ccc.tar.gz | |
Merge branch 'main' of https://github.com/mazeincoding/AppCut
Diffstat (limited to 'apps/web/src/components')
| -rw-r--r-- | apps/web/src/components/editor-header.tsx | 17 | ||||
| -rw-r--r-- | apps/web/src/components/editor/audio-waveform.tsx | 115 | ||||
| -rw-r--r-- | apps/web/src/components/editor/media-panel.tsx | 72 | ||||
| -rw-r--r-- | apps/web/src/components/editor/preview-panel.tsx | 67 | ||||
| -rw-r--r-- | apps/web/src/components/editor/project-name-editor.tsx | 110 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline.tsx | 97 | ||||
| -rw-r--r-- | apps/web/src/components/ui/video-player.tsx | 196 |
7 files changed, 495 insertions, 179 deletions
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()} + /> + ); +} |
