diff --git a/app/components/media/TextEditor.tsx b/app/components/media/TextEditor.tsx index 2501d54..929585f 100644 --- a/app/components/media/TextEditor.tsx +++ b/app/components/media/TextEditor.tsx @@ -96,6 +96,7 @@ export default function TextEditor() { className="w-full h-8 px-2 text-sm bg-muted/50 border border-border rounded-md text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary" > Arial + Arial Narrow Helvetica Times Georgia diff --git a/app/components/timeline/Scrubber.tsx b/app/components/timeline/Scrubber.tsx index 067fd7e..7a93ad9 100644 --- a/app/components/timeline/Scrubber.tsx +++ b/app/components/timeline/Scrubber.tsx @@ -1,6 +1,8 @@ import React, { useState, useRef, useCallback, useEffect } from "react"; import { DEFAULT_TRACK_HEIGHT, type ScrubberState, type Transition } from "./types"; -import { Trash2 } from "lucide-react"; +import { Trash2, Edit, Volume2, VolumeX, Settings } from "lucide-react"; +import { TextPropertiesEditor } from "./TextPropertiesEditor"; +import { VolumeControl } from "./VolumeControl"; // something something for the css not gonna bother with it for now export interface SnapConfig { @@ -54,6 +56,8 @@ export const Scrubber: React.FC = ({ x: number; y: number; }>({ visible: false, x: 0, y: 0 }); + const [isEditingText, setIsEditingText] = useState(false); + const [isEditingVolume, setIsEditingVolume] = useState(false); const MINIMUM_WIDTH = 20; @@ -357,7 +361,7 @@ export const Scrubber: React.FC = ({ isDragging, isResizing, resizeMode, - scrubber, + scrubber, // Make sure scrubber is in dependencies to get fresh reference timelineWidth, checkCollisionWithTrack, onUpdate, @@ -388,7 +392,7 @@ export const Scrubber: React.FC = ({ // Handle deletion with Delete/Backspace keys useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (isSelected && (e.key === "Delete" || e.key === "Backspace")) { + if (isSelected) { // Prevent default behavior and check if we're not in an input field const target = e.target as HTMLElement; const isInputElement = @@ -397,9 +401,21 @@ export const Scrubber: React.FC = ({ target.contentEditable === "true" || target.isContentEditable; - if (!isInputElement && onDelete) { - e.preventDefault(); - onDelete(scrubber.id); + if (!isInputElement) { + if ((e.key === "Delete" || e.key === "Backspace") && onDelete) { + e.preventDefault(); + onDelete(scrubber.id); + } else if (e.key === "m" || e.key === "M") { + // Toggle mute for audio/video scrubbers with 'M' key + if (scrubber.mediaType === "video" || scrubber.mediaType === "audio") { + e.preventDefault(); + const updatedScrubber: ScrubberState = { + ...scrubber, + muted: !scrubber.muted, + }; + onUpdate(updatedScrubber); + } + } } } }; @@ -408,7 +424,7 @@ export const Scrubber: React.FC = ({ document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); } - }, [isSelected, onDelete, scrubber.id]); + }, [isSelected, onDelete, scrubber, onUpdate]); // Professional scrubber colors based on media type const getScrubberColor = () => { @@ -474,6 +490,48 @@ export const Scrubber: React.FC = ({ setContextMenu({ visible: false, x: 0, y: 0 }); }, [onDelete, scrubber.id]); + // Handle context menu edit text action + const handleContextMenuEditText = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setIsEditingText(true); + setContextMenu({ visible: false, x: 0, y: 0 }); + }, []); + + // Handle context menu mute toggle action + const handleContextMenuMuteToggle = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const updatedScrubber: ScrubberState = { + ...scrubber, + muted: !scrubber.muted, + }; + onUpdate(updatedScrubber); + + // Close context menu + setContextMenu({ visible: false, x: 0, y: 0 }); + }, [scrubber, onUpdate]); + + // Handle context menu volume settings action + const handleContextMenuVolumeSettings = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setIsEditingVolume(true); + setContextMenu({ visible: false, x: 0, y: 0 }); + }, []); + + // Handle text properties save + const handleTextPropertiesSave = useCallback((updatedTextProperties: import("./types").TextProperties) => { + const updatedScrubber: ScrubberState = { + ...scrubber, + text: updatedTextProperties, + }; + onUpdate(updatedScrubber); + }, [scrubber, onUpdate]); + // Add click outside listener for context menu useEffect(() => { if (contextMenu.visible) { @@ -497,7 +555,7 @@ export const Scrubber: React.FC = ({ top: `${(scrubber.y || 0) * DEFAULT_TRACK_HEIGHT + 2}px`, height: `${DEFAULT_TRACK_HEIGHT - 4}px`, minWidth: "20px", - zIndex: isDragging || isResizing ? 1000 : isSelected ? 20 : 15, + zIndex: isDragging || isResizing ? 1000 : isSelected ? 100 : 50, }} onMouseDown={(e) => handleMouseDown(e, "drag")} onContextMenu={handleContextMenu} @@ -515,6 +573,13 @@ export const Scrubber: React.FC = ({ {scrubber.name} + {/* Mute indicator for audio/video */} + {(scrubber.mediaType === "video" || scrubber.mediaType === "audio") && scrubber.muted && ( + + + + )} + {/* Left resize handle - more visible */} {scrubber.mediaType !== "video" && scrubber.mediaType !== "audio" && ( = ({ top: `${contextMenu.y}px`, }} > + {/* Show Edit Text option only for text scrubbers */} + {scrubber.mediaType === "text" && scrubber.text && ( + + + Edit Text + + )} + + {/* Show Mute/Unmute option for video and audio scrubbers */} + {(scrubber.mediaType === "video" || scrubber.mediaType === "audio") && ( + + {scrubber.muted ? ( + <> + + Unmute + > + ) : ( + <> + + Mute + > + )} + + )} + + {/* Show Volume Settings option for video and audio scrubbers */} + {(scrubber.mediaType === "video" || scrubber.mediaType === "audio") && ( + + + Volume Settings + + )} = ({ )} + + {/* Text Properties Editor Modal */} + {isEditingText && scrubber.mediaType === "text" && scrubber.text && ( + setIsEditingText(false)} + onSave={handleTextPropertiesSave} + scrubberId={scrubber.id} + /> + )} + + {/* Volume Control Modal */} + {isEditingVolume && (scrubber.mediaType === "video" || scrubber.mediaType === "audio") && ( + setIsEditingVolume(false)} + onSave={onUpdate} + scrubberId={scrubber.id} + /> + )} > ); }; diff --git a/app/components/timeline/TextPropertiesEditor.tsx b/app/components/timeline/TextPropertiesEditor.tsx new file mode 100644 index 0000000..4d5ec12 --- /dev/null +++ b/app/components/timeline/TextPropertiesEditor.tsx @@ -0,0 +1,264 @@ +import React, { useState, useEffect } from "react"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { Badge } from "~/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { Separator } from "~/components/ui/separator"; +import { + AlignLeft, + AlignCenter, + AlignRight, + Bold, + Type, + X, + Check, +} from "lucide-react"; +import type { TextProperties } from "./types"; + +interface TextPropertiesEditorProps { + textProperties: TextProperties; + isOpen: boolean; + onClose: () => void; + onSave: (updatedProperties: TextProperties) => void; + scrubberId: string; +} + +export const TextPropertiesEditor: React.FC = ({ + textProperties, + isOpen, + onClose, + onSave, + scrubberId, +}) => { + const [textContent, setTextContent] = useState(textProperties.textContent); + const [fontSize, setFontSize] = useState(textProperties.fontSize); + const [fontFamily, setFontFamily] = useState(textProperties.fontFamily); + const [color, setColor] = useState(textProperties.color); + const [textAlign, setTextAlign] = useState<"left" | "center" | "right">( + textProperties.textAlign + ); + const [fontWeight, setFontWeight] = useState<"normal" | "bold">( + textProperties.fontWeight + ); + + // Reset form when properties change + useEffect(() => { + setTextContent(textProperties.textContent); + setFontSize(textProperties.fontSize); + setFontFamily(textProperties.fontFamily); + setColor(textProperties.color); + setTextAlign(textProperties.textAlign); + setFontWeight(textProperties.fontWeight); + }, [textProperties]); + + const handleSave = () => { + const updatedProperties: TextProperties = { + textContent, + fontSize, + fontFamily, + color, + textAlign, + fontWeight, + }; + onSave(updatedProperties); + onClose(); + }; + + const handleCancel = () => { + // Reset to original values + setTextContent(textProperties.textContent); + setFontSize(textProperties.fontSize); + setFontFamily(textProperties.fontFamily); + setColor(textProperties.color); + setTextAlign(textProperties.textAlign); + setFontWeight(textProperties.fontWeight); + onClose(); + }; + + if (!isOpen) return null; + + return ( + + + + + + + + Edit Text Properties + + + + + + + + {/* Text Content */} + + Content + setTextContent(e.target.value)} + className="w-full h-20 p-3 text-sm bg-muted/50 border border-border rounded-md text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary resize-none" + placeholder="Enter your text..." + /> + + + {/* Font Size & Family Row */} + + + Size + setFontSize(parseInt(e.target.value) || 48)} + className="h-8 text-sm" + /> + + + Font + setFontFamily(e.target.value)} + className="w-full h-8 px-2 text-sm bg-muted/50 border border-border rounded-md text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary" + > + Arial + Arial Narrow + Helvetica + Times + Georgia + Verdana + Impact + + + + + + + {/* Style Controls */} + + Style + + {/* Text Alignment */} + + + Alignment + + + {( + [ + { value: "left", icon: AlignLeft, label: "Left" }, + { value: "center", icon: AlignCenter, label: "Center" }, + { value: "right", icon: AlignRight, label: "Right" }, + ] as const + ).map(({ value, icon: Icon, label }) => ( + setTextAlign(value)} + className="flex-1 h-8 rounded-none border-0" + title={label} + > + + + ))} + + + + {/* Font Weight & Color */} + + + + Weight + + + {(["normal", "bold"] as const).map((weight) => ( + setFontWeight(weight)} + className="flex-1 h-8 rounded-none border-0 text-xs" + title={weight} + > + {weight === "normal" ? ( + "Normal" + ) : ( + + )} + + ))} + + + + + Color + + setColor(e.target.value)} + className="w-full h-8 bg-muted/50 border border-border rounded-md cursor-pointer" + /> + + {color.toUpperCase()} + + + + + + + + + {/* Preview */} + + Preview + + {textContent || "Sample text"} + + + + {/* Action Buttons */} + + + Cancel + + + + Save Changes + + + + + + + ); +}; diff --git a/app/components/timeline/TimelineTracks.tsx b/app/components/timeline/TimelineTracks.tsx index fe171fd..f7e776d 100644 --- a/app/components/timeline/TimelineTracks.tsx +++ b/app/components/timeline/TimelineTracks.tsx @@ -113,7 +113,7 @@ export const TimelineTracks: React.FC = ({ size="sm" onClick={() => onDeleteTrack(track.id)} className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10 relative z-10" - title={`Delete Track ${index + 1}`} + title={`Delete Track ${timeline.tracks.length - index}`} > @@ -240,7 +240,7 @@ export const TimelineTracks: React.FC = ({ className="absolute left-2 top-1 text-xs text-muted-foreground font-medium pointer-events-none select-none z-[5]" style={{ userSelect: "none" }} > - Track {trackIndex + 1} + Track {timeline.tracks.length - trackIndex} {/* Grid lines */} diff --git a/app/components/timeline/VolumeControl.tsx b/app/components/timeline/VolumeControl.tsx new file mode 100644 index 0000000..99bad40 --- /dev/null +++ b/app/components/timeline/VolumeControl.tsx @@ -0,0 +1,140 @@ +import React, { useState, useCallback } from "react"; +import { Volume2, VolumeX } from "lucide-react"; +import { type ScrubberState } from "./types"; + +interface VolumeControlProps { + scrubber: ScrubberState; + isOpen: boolean; + onClose: () => void; + onSave: (updatedScrubber: ScrubberState) => void; + scrubberId: string; +} + +export const VolumeControl: React.FC = ({ + scrubber, + isOpen, + onClose, + onSave, + scrubberId, +}) => { + const [volume, setVolume] = useState(scrubber.volume || 1); + const [muted, setMuted] = useState(scrubber.muted || false); + + const handleSave = useCallback(() => { + const updatedScrubber: ScrubberState = { + ...scrubber, + volume, + muted, + }; + onSave(updatedScrubber); + onClose(); + }, [scrubber, volume, muted, onSave, onClose]); + + const handleCancel = useCallback(() => { + // Reset to original values + setVolume(scrubber.volume || 1); + setMuted(scrubber.muted || false); + onClose(); + }, [scrubber.volume, scrubber.muted, onClose]); + + const handleMuteToggle = useCallback(() => { + setMuted(!muted); + }, [muted]); + + const handleVolumeChange = useCallback((e: React.ChangeEvent) => { + const newVolume = parseFloat(e.target.value); + setVolume(newVolume); + // Automatically unmute when volume is changed + if (newVolume > 0 && muted) { + setMuted(false); + } + }, [muted]); + + if (!isOpen) return null; + + return ( + + + + Volume Control + + ✕ + + + + + {/* Mute Toggle */} + + Mute + + {muted ? : } + {muted ? "Muted" : "Unmuted"} + + + + {/* Volume Slider */} + + + Volume + + {Math.round(volume * 100)}% + + + + + 0% + 50% + 100% + + + + {/* Preview Section */} + + Preview: + + {muted ? : } + + {muted ? "Muted" : `Volume: ${Math.round(volume * 100)}%`} + + + + + + {/* Action Buttons */} + + + Cancel + + + Apply + + + + + ); +}; diff --git a/app/components/timeline/types.ts b/app/components/timeline/types.ts index d97da0e..4531359 100644 --- a/app/components/timeline/types.ts +++ b/app/components/timeline/types.ts @@ -60,6 +60,10 @@ export interface ScrubberState extends MediaBinItem { // for video scrubbers (and audio in the future) trimBefore: number | null; // in frames trimAfter: number | null; // in frames + + // Audio controls + muted: boolean; // Whether the audio is muted + volume: number; // Volume level (0-1, default 1) } // state of the track in the timeline @@ -91,6 +95,10 @@ export interface TimelineDataItem { // for video scrubbers (and audio in the future) trimBefore: number | null; // in frames trimAfter: number | null; // in frames + + // Audio controls + muted: boolean; // Whether the audio is muted + volume: number; // Volume level (0-1, default 1) })[]; transitions: { [id: string]: Transition }; } diff --git a/app/hooks/useMediaBin.ts b/app/hooks/useMediaBin.ts index f2df232..7e63542 100644 --- a/app/hooks/useMediaBin.ts +++ b/app/hooks/useMediaBin.ts @@ -267,10 +267,22 @@ export const useMediaBin = (handleDeleteScrubbersByMediaBinId: (mediaBinId: stri const handleDeleteMedia = useCallback(async (item: MediaBinItem) => { try { - // Extract filename from mediaUrlRemote URL + // For text items, just remove from UI without server deletion + if (item.mediaType === "text") { + console.log(`Removing text item from media bin: ${item.name}`); + // Remove from media bin state + setMediaBinItems(prev => prev.filter(binItem => binItem.id !== item.id)); + // Also remove any scrubbers from the timeline that use this media + if (handleDeleteScrubbersByMediaBinId) { + handleDeleteScrubbersByMediaBinId(item.id); + } + return; + } + + // For media files (video/image/audio), they should have a remote URL if (!item.mediaUrlRemote) { console.error('No remote URL found for media item'); - return; + throw new Error('Cannot delete media file: No remote URL found. The file may not have been uploaded properly.'); } // Parse the URL and extract filename from the path @@ -280,7 +292,7 @@ export const useMediaBin = (handleDeleteScrubbersByMediaBinId: (mediaBinId: stri if (!encodedFilename) { console.error('Could not extract filename from URL:', item.mediaUrlRemote); - return; + throw new Error('Could not extract filename from URL'); } // Decode the filename @@ -298,9 +310,12 @@ export const useMediaBin = (handleDeleteScrubbersByMediaBinId: (mediaBinId: stri } } else { console.error('Failed to delete media:', result.error); + throw new Error(`Failed to delete media: ${result.error}`); } } catch (error) { console.error('Error deleting media:', error); + // Re-throw the error so the caller can handle it appropriately + throw error; } }, [handleDeleteScrubbersByMediaBinId]); diff --git a/app/hooks/useTimeline.ts b/app/hooks/useTimeline.ts index b46da20..68016de 100644 --- a/app/hooks/useTimeline.ts +++ b/app/hooks/useTimeline.ts @@ -150,6 +150,10 @@ export const useTimeline = () => { // for video scrubbers (and audio in the future) trimBefore: scrubber.trimBefore, trimAfter: scrubber.trimAfter, + + // Audio controls + muted: scrubber.muted, + volume: scrubber.volume, left_transition_id: scrubber.left_transition_id, right_transition_id: scrubber.right_transition_id, @@ -211,7 +215,7 @@ export const useTimeline = () => { }; setTimeline((prev) => ({ ...prev, - tracks: [...prev.tracks, newTrack], + tracks: [newTrack, ...prev.tracks], // Prepend instead of append for bottom-to-top ordering })); }, []); @@ -379,6 +383,10 @@ export const useTimeline = () => { // for video scrubbers (and audio in the future) trimBefore: null, trimAfter: null, + + // Audio controls - initialize with defaults + muted: false, + volume: 1, left_transition_id: null, right_transition_id: null, diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 0f5d275..cd49d73 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -333,20 +333,21 @@ export default function TimelineEditor() { // Global spacebar play/pause functionality - like original useEffect(() => { const handleGlobalKeyPress = (event: KeyboardEvent) => { - // Only handle spacebar when not focused on input elements - if (event.code === "Space") { - const target = event.target as HTMLElement; - const isInputElement = - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.contentEditable === "true" || - target.isContentEditable; - - // If user is typing in an input field, don't interfere - if (isInputElement) { - return; - } + // Only handle shortcuts when not focused on input elements + const target = event.target as HTMLElement; + const isInputElement = + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.contentEditable === "true" || + target.isContentEditable; + + // If user is typing in an input field, don't interfere + if (isInputElement) { + return; + } + // Handle different key shortcuts + if (event.code === "Space") { // Prevent spacebar from scrolling the page event.preventDefault(); @@ -358,6 +359,80 @@ export default function TimelineEditor() { player.play(); } } + } else if (event.key === "s" || event.key === "S") { + // Handle split shortcut + event.preventDefault(); + handleSplitClick(); + } else if (event.key === "r" || event.key === "R") { + // Reset ALL stuck interaction states with 'R' key + event.preventDefault(); + console.log('Resetting all interaction states...'); + + let resetCount = 0; + const resetActions: string[] = []; + + // 1. Reset all stuck dragging states + const allScrubbers = getAllScrubbers(); + const draggingItems = allScrubbers.filter(scrubber => scrubber.is_dragging); + + if (draggingItems.length > 0) { + console.log('Found stuck dragging items:', draggingItems.map(item => item.id)); + draggingItems.forEach((item) => { + handleUpdateScrubber({ + ...item, + is_dragging: false, + }); + }); + resetCount += draggingItems.length; + resetActions.push(`${draggingItems.length} dragging state${draggingItems.length > 1 ? 's' : ''}`); + } + + // 2. Clear any stuck selection state + if (selectedItem) { + console.log('Clearing stuck selection:', selectedItem); + setSelectedItem(null); + resetCount++; + resetActions.push('selection'); + } + + // 3. Clear preview selection state + if (selectedScrubberId) { + console.log('Clearing stuck scrubber selection:', selectedScrubberId); + setSelectedScrubberId(null); + resetCount++; + resetActions.push('scrubber selection'); + } + + // 4. Force clear any stuck pointer capture by dispatching pointer events + try { + // Create and dispatch a synthetic pointerup event to clear any stuck captures + const pointerUpEvent = new PointerEvent('pointerup', { + bubbles: true, + cancelable: true, + pointerId: 1, + button: 0 + }); + document.dispatchEvent(pointerUpEvent); + + // Also dispatch mouseup as fallback + const mouseUpEvent = new MouseEvent('mouseup', { + bubbles: true, + cancelable: true, + button: 0 + }); + document.dispatchEvent(mouseUpEvent); + + resetActions.push('pointer events'); + resetCount++; + } catch (error) { + console.warn('Failed to dispatch cleanup events:', error); + } + + if (resetCount > 0) { + toast.success(`Reset: ${resetActions.join(', ')}`); + } else { + toast.info('No stuck states found'); + } } }; @@ -367,7 +442,7 @@ export default function TimelineEditor() { return () => { document.removeEventListener("keydown", handleGlobalKeyPress); }; - }, []); // Empty dependency array since we're accessing playerRef.current directly + }, [handleSplitClick, getAllScrubbers, handleUpdateScrubber, selectedItem, selectedScrubberId, setSelectedItem, setSelectedScrubberId]); // Include dependencies // Fetch GitHub star count @@ -636,6 +711,7 @@ export default function TimelineEditor() { size="sm" onClick={togglePlayback} className="h-6 w-6 p-0" + title={isPlaying ? "Pause (Space)" : "Play (Space)"} > {isPlaying ? ( @@ -720,7 +796,7 @@ export default function TimelineEditor() { title="Split selected scrubber at ruler position" > - Split + Split (S) { if (!isProduction) { - return fastapi ? "http://127.0.0.1:3000" : "http://localhost:8000"; + return fastapi ? "http://127.0.0.1:8000" : "http://localhost:3000"; } if (typeof window !== "undefined" && !fastapi) { diff --git a/app/utils/llm-handler.ts b/app/utils/llm-handler.ts index 1b840d5..e3efef6 100644 --- a/app/utils/llm-handler.ts +++ b/app/utils/llm-handler.ts @@ -31,6 +31,7 @@ export function llmAddScrubberByName( trackNumber: number, positionSeconds: number, pixelsPerSecond: number, + timeline: TimelineState, handleDropOnTrack: (item: MediaBinItem, trackId: string, dropLeftPx: number) => void ) { const scrubber = mediaBinItems.find(item => @@ -39,7 +40,14 @@ export function llmAddScrubberByName( if (!scrubber) { throw new Error(`Media item with name "${name}" not found`); } - const trackId = `track-${trackNumber}`; + + // Convert track number to array index (bottom-to-top ordering) + const trackIndex = timeline.tracks.length - trackNumber; + if (trackIndex < 0 || trackIndex >= timeline.tracks.length) { + throw new Error(`Track ${trackNumber} does not exist`); + } + + const trackId = timeline.tracks[trackIndex].id; const dropLeftPx = positionSeconds * pixelsPerSecond; handleDropOnTrack(scrubber, trackId, dropLeftPx); } @@ -61,7 +69,7 @@ export function llmMoveScrubber( const updatedScrubber: ScrubberState = { ...scrubber, left: newPositionSeconds * pixelsPerSecond, - y: newTrackNumber - 1 // Convert to 0-based index + y: timeline.tracks.length - newTrackNumber // Convert track number to 0-based visual index (bottom-to-top ordering) }; handleUpdateScrubber(updatedScrubber); @@ -127,7 +135,7 @@ export function llmDeleteTrackByNumber( timeline: TimelineState, handleDeleteTrack: (trackId: string) => void ) { - const trackIndex = trackNumber - 1; // Convert to 0-based index + const trackIndex = timeline.tracks.length - trackNumber; // Convert track number to array index (bottom-to-top ordering) if (trackIndex < 0 || trackIndex >= timeline.tracks.length) { throw new Error(`Track ${trackNumber} does not exist`); } @@ -595,7 +603,7 @@ export function llmGetScrubberAtPosition( pixelsPerSecond: number, timeline: TimelineState ): ScrubberState | null { - const trackIndex = trackNumber - 1; + const trackIndex = timeline.tracks.length - trackNumber; // Convert track number to array index (bottom-to-top ordering) if (trackIndex < 0 || trackIndex >= timeline.tracks.length) { return null; } diff --git a/app/video-compositions/DragDrop.tsx b/app/video-compositions/DragDrop.tsx index 32f05fb..cdcbb3d 100644 --- a/app/video-compositions/DragDrop.tsx +++ b/app/video-compositions/DragDrop.tsx @@ -233,9 +233,26 @@ export const SelectionOutline: React.FC<{ console.log("onPointerDown is called", ScrubberState.id); setSelectedItem(ScrubberState.id); - startDragging(e); + + // Add failsafe timeout to prevent stuck dragging + const dragTimeout = setTimeout(() => { + console.warn('Drag timeout reached, forcing cleanup for:', ScrubberState.id); + changeItem({ + ...ScrubberState, + is_dragging: false, + }); + }, 10000); // 10 second timeout + + // Store timeout reference for cleanup + const originalStartDragging = startDragging; + const wrappedStartDragging = (event: PointerEvent | React.MouseEvent) => { + clearTimeout(dragTimeout); // Clear timeout since we're properly handling drag + originalStartDragging(event); + }; + + wrappedStartDragging(e); }, - [ScrubberState.id, setSelectedItem, startDragging] + [ScrubberState, setSelectedItem, startDragging, changeItem] ); return ( @@ -318,6 +335,47 @@ export const SortedOutlines: React.FC<{ [timeline] ); + // Reset any stuck dragging states if there are multiple items stuck in dragging mode + React.useEffect(() => { + const draggingItems = timeline.tracks + .flatMap((track: TrackState) => track.scrubbers) + .filter((ScrubberState) => ScrubberState.is_dragging); + + // If there are multiple items stuck dragging or items dragging without user interaction + if (draggingItems.length > 1) { + console.warn('Multiple items stuck in dragging state, resetting...', draggingItems.map(item => item.id)); + draggingItems.forEach((item) => { + handleUpdateScrubber({ + ...item, + is_dragging: false, + }); + }); + } + + // Also check for items that have been dragging for too long (potential stuck state) + // This uses a simple heuristic - if there are dragging items but no current pointer activity + if (draggingItems.length > 0) { + // Set a timeout to check if dragging state persists without user interaction + const timeoutId = setTimeout(() => { + const stillDraggingItems = timeline.tracks + .flatMap((track: TrackState) => track.scrubbers) + .filter((ScrubberState) => ScrubberState.is_dragging); + + if (stillDraggingItems.length > 0) { + console.warn('Detected potentially stuck dragging states, auto-clearing...', stillDraggingItems.map(item => item.id)); + stillDraggingItems.forEach((item) => { + handleUpdateScrubber({ + ...item, + is_dragging: false, + }); + }); + } + }, 5000); // 5 second timeout + + return () => clearTimeout(timeoutId); + } + }, [timeline, handleUpdateScrubber]); + return itemsToDisplay.map((ScrubberState) => { return ( - + scrubber.muted ? 0 : (scrubber.volume || 1)} + /> ); break; @@ -156,7 +162,15 @@ export function TimelineComposition({ const audioUrl = isRendering ? scrubber.mediaUrlRemote : scrubber.mediaUrlLocal; - content = ; + content = ( + scrubber.muted ? 0 : (scrubber.volume || 1)} + /> + ); break; } default: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..5b23a1c --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +onlyBuiltDependencies: + - '@tailwindcss/oxide' + - esbuild