import { useRef, useEffect, useState } from "react"; import { formatTime, formatDetailedTime } from "../lib/timeUtils"; import { generateThumbnail, generateSolidColor } from "../lib/videoUtils"; import { Segment } from "./ClipSegments"; import Modal from "./Modal"; import { trimVideo } from "../services/videoApi"; import logger from "../lib/logger"; import '../styles/TimelineControls.css'; import '../styles/TwoRowTooltip.css'; import playIcon from '../assets/play-icon.svg'; import pauseIcon from '../assets/pause-icon.svg'; import playFromBeginningIcon from '../assets/play-from-beginning-icon.svg'; import segmentEndIcon from '../assets/segment-end-new.svg'; import segmentStartIcon from '../assets/segment-start-new.svg'; import segmentNewStartIcon from '../assets/segment-start-new-cutaway.svg'; import segmentNewEndIcon from '../assets/segment-end-new-cutaway.svg'; // Add styles for the media page link const mediaPageLinkStyles = { color: '#007bff', textDecoration: 'none', fontWeight: 'bold', '&:hover': { textDecoration: 'underline', color: '#0056b3' } } as const; interface TimelineControlsProps { currentTime: number; duration: number; thumbnails: string[]; trimStart: number; trimEnd: number; splitPoints: number[]; zoomLevel: number; clipSegments: Segment[]; onTrimStartChange: (time: number) => void; onTrimEndChange: (time: number) => void; onZoomChange: (level: number) => void; onSeek: (time: number) => void; videoRef: React.RefObject; onSave?: () => void; onSaveACopy?: () => void; onSaveSegments?: () => void; isPreviewMode?: boolean; hasUnsavedChanges?: boolean; isIOSUninitialized?: boolean; isPlaying: boolean; setIsPlaying: (playing: boolean) => void; onPlayPause: () => void; // Add this prop } // Function to calculate and constrain tooltip position to keep it on screen const constrainTooltipPosition = (positionPercent: number) => { // Default position logic (centered) let leftValue = `${positionPercent}%`; let transform = 'translateX(-50%)'; // Near left edge (first 17%) if (positionPercent < 17) { // Position the left edge of tooltip at 0%, no transform leftValue = '0%'; transform = 'none'; } // Near right edge (last 17%) else if (positionPercent > 83) { // Position the right edge of tooltip at 100% leftValue = '100%'; transform = 'translateX(-100%)'; } return { left: leftValue, transform }; }; const TimelineControls = ({ currentTime, duration, thumbnails, trimStart, trimEnd, splitPoints, zoomLevel, clipSegments, onTrimStartChange, onTrimEndChange, onZoomChange, onSeek, videoRef, onSave, onSaveACopy, onSaveSegments, isPreviewMode, hasUnsavedChanges = false, isIOSUninitialized = false, isPlaying, setIsPlaying, onPlayPause // Add this prop }: TimelineControlsProps) => { const timelineRef = useRef(null); const leftHandleRef = useRef(null); const rightHandleRef = useRef(null); const [selectedSegmentId, setSelectedSegmentId] = useState(null); const [showEmptySpaceTooltip, setShowEmptySpaceTooltip] = useState(false); const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 }); const [clickedTime, setClickedTime] = useState(0); const [isZoomDropdownOpen, setIsZoomDropdownOpen] = useState(false); const [availableSegmentDuration, setAvailableSegmentDuration] = useState(30); // Default 30 seconds const [isPlayingSegment, setIsPlayingSegment] = useState(false); const [activeSegment, setActiveSegment] = useState(null); const [displayTime, setDisplayTime] = useState(0); // Track when we should continue playing (clicking play after boundary stop) const [continuePastBoundary, setContinuePastBoundary] = useState(false); // Reference for the scrollable container const scrollContainerRef = useRef(null); // Helper function for time adjustment buttons to maintain playback state const handleTimeAdjustment = (offsetSeconds: number) => (e: React.MouseEvent) => { e.stopPropagation(); // Calculate new time based on offset (positive or negative) const newTime = offsetSeconds < 0 ? Math.max(0, clickedTime + offsetSeconds) // For negative offsets (going back) : Math.min(duration, clickedTime + offsetSeconds); // For positive offsets (going forward) // Save the current playing state before seeking const wasPlaying = isPlayingSegment; // Seek to the new time onSeek(newTime); // Update both clicked time and display time setClickedTime(newTime); setDisplayTime(newTime); // Resume playback if it was playing before if (wasPlaying && videoRef.current) { videoRef.current.play(); setIsPlayingSegment(true); } }; // Enhanced helper for continuous time adjustment when button is held down const handleContinuousTimeAdjustment = (offsetSeconds: number) => { // Fixed adjustment amount - exactly 50ms each time const adjustmentValue = offsetSeconds; // Hold timer for continuous adjustment let holdTimer: NodeJS.Timeout | null = null; let continuousTimer: NodeJS.Timeout | null = null; // Store the last time value to correctly calculate the next increment let lastTimeValue = clickedTime; // Function to perform time adjustment const adjustTime = () => { // Calculate new time based on fixed offset (positive or negative) const newTime = adjustmentValue < 0 ? Math.max(0, lastTimeValue + adjustmentValue) // For negative offsets (going back) : Math.min(duration, lastTimeValue + adjustmentValue); // For positive offsets (going forward) // Update our last time value for next adjustment lastTimeValue = newTime; // Save the current playing state before seeking const wasPlaying = isPlayingSegment; // Seek to the new time onSeek(newTime); // Update both clicked time and display time setClickedTime(newTime); setDisplayTime(newTime); // Update tooltip position if (timelineRef.current) { const rect = timelineRef.current.getBoundingClientRect(); const positionPercent = (newTime / duration) * 100; const xPos = rect.left + (rect.width * (positionPercent / 100)); setTooltipPosition({ x: xPos, y: rect.top - 10 }); // Find if we're in a segment at the new time const segmentAtTime = clipSegments.find( seg => newTime >= seg.startTime && newTime <= seg.endTime ); if (segmentAtTime) { // Show segment tooltip setSelectedSegmentId(segmentAtTime.id); setShowEmptySpaceTooltip(false); } else { // Show cutaway tooltip setSelectedSegmentId(null); const availableSpace = calculateAvailableSpace(newTime); setAvailableSegmentDuration(availableSpace); setShowEmptySpaceTooltip(true); } } // Resume playback if it was playing before if (wasPlaying && videoRef.current) { videoRef.current.play(); setIsPlayingSegment(true); } }; // Return mouse event handlers with touch support return { onMouseDown: (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); // Update the initial last time value lastTimeValue = clickedTime; // Perform initial adjustment adjustTime(); // Start continuous adjustment after 1.5s hold holdTimer = setTimeout(() => { // After 1.5s delay, start adjusting at a slower pace (every 200ms) continuousTimer = setInterval(adjustTime, 200); }, 750); // Add mouse up and leave handlers to document to ensure we catch the release const clearTimers = () => { if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; } if (continuousTimer) { clearInterval(continuousTimer); continuousTimer = null; } document.removeEventListener('mouseup', clearTimers); document.removeEventListener('mouseleave', clearTimers); }; document.addEventListener('mouseup', clearTimers); document.addEventListener('mouseleave', clearTimers); }, onTouchStart: (e: React.TouchEvent) => { e.stopPropagation(); e.preventDefault();21 // Update the initial last time value lastTimeValue = clickedTime; // Perform initial adjustment adjustTime(); // Start continuous adjustment after 1.5s hold holdTimer = setTimeout(() => { // After 1.5s delay, start adjusting at a slower pace (every 200ms) continuousTimer = setInterval(adjustTime, 200); }, 750); // Add touch end handler to ensure we catch the release const clearTimers = () => { if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; } if (continuousTimer) { clearInterval(continuousTimer); continuousTimer = null; } document.removeEventListener('touchend', clearTimers); document.removeEventListener('touchcancel', clearTimers); }; document.addEventListener('touchend', clearTimers); document.addEventListener('touchcancel', clearTimers); }, onClick: (e: React.MouseEvent) => { // This prevents the click event from firing twice e.stopPropagation(); } }; }; // Modal states const [showSaveModal, setShowSaveModal] = useState(false); const [showSaveAsModal, setShowSaveAsModal] = useState(false); const [showSaveSegmentsModal, setShowSaveSegmentsModal] = useState(false); const [showProcessingModal, setShowProcessingModal] = useState(false); const [showSuccessModal, setShowSuccessModal] = useState(false); const [showErrorModal, setShowErrorModal] = useState(false); const [successMessage, setSuccessMessage] = useState(""); const [errorMessage, setErrorMessage] = useState(""); const [redirectUrl, setRedirectUrl] = useState(""); const [saveType, setSaveType] = useState<"save" | "copy" | "segments">("save"); // Calculate positions as percentages const currentTimePercent = duration > 0 ? (currentTime / duration) * 100 : 0; const trimStartPercent = duration > 0 ? (trimStart / duration) * 100 : 0; const trimEndPercent = duration > 0 ? (trimEnd / duration) * 100 : 0; // No need for an extra effect here as we handle displayTime updates in the segment playback effect // Save and API handlers const handleSaveConfirm = async () => { // Close confirmation modal and show processing modal setShowSaveModal(false); setShowProcessingModal(true); setSaveType("save"); try { // Format segments data for API request const segments = clipSegments.map(segment => ({ startTime: formatDetailedTime(segment.startTime), endTime: formatDetailedTime(segment.endTime) })); const mediaId = typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId || null; const redirectURL = typeof window !== 'undefined' && (window as any).MEDIA_DATA?.redirectURL || null; // Log the request details for debugging logger.debug("Save request:", { mediaId, segments, saveAsCopy: false, redirectURL }); const response = await trimVideo(mediaId, { segments, saveAsCopy: false }); // Log the response for debugging logger.debug("Save response:", response); // Hide processing modal setShowProcessingModal(false); // Check if response indicates success (200 OK) if (response.status === 200) { // For "Save", use the redirectURL from the window or response const finalRedirectUrl = redirectURL || response.url_redirect; logger.debug("Using redirect URL:", finalRedirectUrl); setRedirectUrl(finalRedirectUrl); setSuccessMessage("Video saved successfully!"); // Show success modal setShowSuccessModal(true); } else if (response.status === 400) { // Set error message from response and show error modal const errorMsg = response.error || "An error occurred during processing"; logger.debug("Save error (400):", errorMsg); setErrorMessage(errorMsg); setShowErrorModal(true); } else { // Handle other status codes as needed logger.debug("Save error (unknown status):", response); setErrorMessage("An unexpected error occurred"); setShowErrorModal(true); } } catch (error) { logger.error("Error processing video:", error); setShowProcessingModal(false); // Set error message and show error modal const errorMsg = error instanceof Error ? error.message : "An error occurred during processing"; logger.debug("Save error (exception):", errorMsg); setErrorMessage(errorMsg); setShowErrorModal(true); } }; const handleSaveAsCopyConfirm = async () => { // Close confirmation modal and show processing modal setShowSaveAsModal(false); setShowProcessingModal(true); setSaveType("copy"); try { // Format segments data for API request const segments = clipSegments.map(segment => ({ startTime: formatDetailedTime(segment.startTime), endTime: formatDetailedTime(segment.endTime) })); const mediaId = typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId || null; const redirectUserMediaURL = typeof window !== 'undefined' && (window as any).MEDIA_DATA?.redirectUserMediaURL || null; // Log the request details for debugging logger.debug("Save as copy request:", { mediaId, segments, saveAsCopy: true, redirectUserMediaURL }); const response = await trimVideo(mediaId, { segments, saveAsCopy: true }); // Log the response for debugging logger.debug("Save as copy response:", response); // Hide processing modal setShowProcessingModal(false); // Check if response indicates success (200 OK) if (response.status === 200) { // For "Save As Copy", use the redirectUserMediaURL from the window const finalRedirectUrl = redirectUserMediaURL || response.url_redirect; logger.debug("Using redirect user media URL:", finalRedirectUrl); setRedirectUrl(finalRedirectUrl); setSuccessMessage("Video saved as a new copy!"); // Show success modal setShowSuccessModal(true); } else if (response.status === 400) { // Set error message from response and show error modal const errorMsg = response.error || "An error occurred during processing"; logger.debug("Save as copy error (400):", errorMsg); setErrorMessage(errorMsg); setShowErrorModal(true); } else { // Handle other status codes as needed logger.debug("Save as copy error (unknown status):", response); setErrorMessage("An unexpected error occurred"); setShowErrorModal(true); } } catch (error) { logger.error("Error processing video:", error); setShowProcessingModal(false); // Set error message and show error modal const errorMsg = error instanceof Error ? error.message : "An error occurred during processing"; logger.debug("Save as copy error (exception):", errorMsg); setErrorMessage(errorMsg); setShowErrorModal(true); } }; const handleSaveSegmentsConfirm = async () => { // Close confirmation modal and show processing modal setShowSaveSegmentsModal(false); setShowProcessingModal(true); setSaveType("segments"); try { // Format segments data for API request, with each segment saved as a separate file const segments = clipSegments.map(segment => ({ startTime: formatDetailedTime(segment.startTime), endTime: formatDetailedTime(segment.endTime), name: segment.name // Include segment name for individual files })); const mediaId = typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId || null; const redirectUserMediaURL = typeof window !== 'undefined' && (window as any).MEDIA_DATA?.redirectUserMediaURL || null; // Log the request details for debugging logger.debug("Save segments request:", { mediaId, segments, saveAsCopy: true, saveIndividualSegments: true, redirectUserMediaURL }); const response = await trimVideo(mediaId, { segments, saveAsCopy: true, saveIndividualSegments: true }); // Log the response for debugging logger.debug("Save segments response:", response); // Hide processing modal setShowProcessingModal(false); // Check if response indicates success (200 OK) if (response.status === 200) { // For "Save Segments", use the redirectUserMediaURL from the window const finalRedirectUrl = redirectUserMediaURL || response.url_redirect; logger.debug("Using redirect user media URL for segments:", finalRedirectUrl); setRedirectUrl(finalRedirectUrl); setSuccessMessage(`${segments.length} segments saved successfully!`); // Show success modal setShowSuccessModal(true); } else if (response.status === 400) { // Set error message from response and show error modal const errorMsg = response.error || "An error occurred during processing"; logger.debug("Save segments error (400):", errorMsg); setErrorMessage(errorMsg); setShowErrorModal(true); } else { // Handle other status codes as needed logger.debug("Save segments error (unknown status):", response); setErrorMessage("An unexpected error occurred"); setShowErrorModal(true); } } catch (error) { // Handle errors logger.error("Error processing video segments:", error); setShowProcessingModal(false); // Set error message and show error modal const errorMsg = error instanceof Error ? error.message : "An error occurred during processing"; logger.debug("Save segments error (exception):", errorMsg); setErrorMessage(errorMsg); setShowErrorModal(true); } }; // Auto-scroll and update tooltip position when seeking to a different time useEffect(() => { if (scrollContainerRef.current && timelineRef.current && zoomLevel > 1) { const containerWidth = scrollContainerRef.current.clientWidth; const timelineWidth = timelineRef.current.clientWidth; const markerPosition = (currentTime / duration) * timelineWidth; // Calculate the position where we want the marker to be visible // (center of the viewport when possible) const desiredScrollPosition = Math.max(0, markerPosition - containerWidth / 2); // Smooth scroll to the desired position scrollContainerRef.current.scrollTo({ left: desiredScrollPosition, behavior: 'smooth' }); // Update tooltip position to stay with the marker const rect = timelineRef.current.getBoundingClientRect(); // Calculate the visible position of the marker after scrolling const containerRect = scrollContainerRef.current.getBoundingClientRect(); const visibleTimelineLeft = rect.left - scrollContainerRef.current.scrollLeft; const markerX = visibleTimelineLeft + (currentTimePercent / 100 * rect.width); // Only update if we have a tooltip showing if (selectedSegmentId !== null || showEmptySpaceTooltip) { setTooltipPosition({ x: markerX, y: rect.top - 10 }); setClickedTime(currentTime); } } }, [currentTime, zoomLevel, duration, selectedSegmentId, showEmptySpaceTooltip, currentTimePercent]); // Effect to check active segment boundaries during playback useEffect(() => { const video = videoRef.current; if (!video || !activeSegment || !isPlayingSegment) { logger.debug("Segment boundary check not active:", { hasVideo: !!video, hasActiveSegment: !!activeSegment, isPlaying: isPlayingSegment }); return; } // Skip segment boundary checking in preview mode (it has its own handler) if (isPreviewMode) { logger.debug("Skipping segment boundary check in preview mode"); return; } logger.debug("Segment boundary check ACTIVATED for segment:", activeSegment.id, "Start:", formatDetailedTime(activeSegment.startTime), "End:", formatDetailedTime(activeSegment.endTime) ); const handleTimeUpdate = () => { const timeLeft = activeSegment.endTime - video.currentTime; // Log every second to show we're actually checking if (Math.round(timeLeft * 10) % 10 === 0) { logger.debug("Segment playback - time remaining:", formatDetailedTime(timeLeft), "Current:", formatDetailedTime(video.currentTime), "End:", formatDetailedTime(activeSegment.endTime), "ContinuePastBoundary:", continuePastBoundary ); } // If we've already passed the segment end, stop immediately if (video.currentTime > activeSegment.endTime) { video.pause(); video.currentTime = activeSegment.endTime; setIsPlayingSegment(false); // Reset continuePastBoundary when stopping at boundary setContinuePastBoundary(false); logger.debug("Passed segment end - setting back to exact boundary:", formatDetailedTime(activeSegment.endTime)); return; } // If we've reached very close to the end of the active segment // Use a small tolerance to ensure we stop as close as possible to boundary // But not exactly at the boundary to avoid rounding errors if (activeSegment.endTime - video.currentTime < 0.05) { if (!continuePastBoundary) { // Pause playback and set the time exactly at the end boundary video.pause(); video.currentTime = activeSegment.endTime; setIsPlayingSegment(false); logger.debug("Paused at segment end boundary:", formatDetailedTime(activeSegment.endTime)); // Look for the next segment after this one (for potential continuation) const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); const nextSegment = sortedSegments.find(seg => seg.startTime > activeSegment.endTime); // If there's a next segment immediately after this one, update the tooltip to show that segment if (nextSegment && Math.abs(nextSegment.startTime - activeSegment.endTime) < 0.1) { logger.debug("Found adjacent next segment:", nextSegment.id); setSelectedSegmentId(nextSegment.id); setActiveSegment(nextSegment); setDisplayTime(nextSegment.startTime); setClickedTime(nextSegment.startTime); video.currentTime = nextSegment.startTime; } } else { // We're continuing past the boundary logger.debug("Continuing past segment boundary:", formatDetailedTime(activeSegment.endTime)); // Reset the flag after we've passed the boundary to ensure we stop at the next boundary if (video.currentTime > activeSegment.endTime) { setContinuePastBoundary(false); logger.debug("Past segment boundary - resetting continuePastBoundary flag"); // Remove the active segment to avoid boundary checking until next segment is activated setActiveSegment(null); sessionStorage.removeItem('continuingPastSegment'); } } } }; // Add event listener for timeupdate to check segment boundaries video.addEventListener('timeupdate', handleTimeUpdate); return () => { video.removeEventListener('timeupdate', handleTimeUpdate); logger.debug("Segment boundary check DEACTIVATED"); }; }, [activeSegment, isPlayingSegment, isPreviewMode, continuePastBoundary, clipSegments]); // Update display time and check for transitions between segments and empty spaces useEffect(() => { // Always update display time to match current video time when playing if (videoRef.current) { // If video is playing, always update the displayed time in the tooltip if (!videoRef.current.paused) { setDisplayTime(currentTime); // Also update clicked time to keep them in sync when playing // This ensures correct time is shown when pausing setClickedTime(currentTime); if (selectedSegmentId !== null) { setIsPlayingSegment(true); } // While playing, continuously check if we're in a segment or empty space // to update the tooltip accordingly, regardless of where we started playing // Check if we're in any segment at current time const segmentAtCurrentTime = clipSegments.find( seg => currentTime >= seg.startTime && currentTime <= seg.endTime ); // Update tooltip position based on current time percentage const newTimePercent = (currentTime / duration) * 100; if (timelineRef.current) { const timelineWidth = timelineRef.current.offsetWidth; const markerX = (newTimePercent / 100) * timelineWidth; setTooltipPosition({ x: markerX, y: timelineRef.current.getBoundingClientRect().top - 10 }); } // Check for the special "continue past segment" state in sessionStorage const isContinuingPastSegment = sessionStorage.getItem('continuingPastSegment') === 'true'; // If we're in a segment now if (segmentAtCurrentTime) { // Get video element reference for boundary checks const video = videoRef.current; // Special check for virtual segments (cutaway playback) // If we have an active virtual segment (negative ID) and we're in a regular segment now, // we need to STOP at the start of this segment - that's the boundary of our cutaway const isPlayingVirtualSegment = activeSegment && activeSegment.id < 0 && isPlayingSegment; // If the active segment is different from the current segment and it's not a virtual segment // and we're not in "continue past boundary" mode, set this segment as the active segment if (activeSegment?.id !== segmentAtCurrentTime.id && !isPlayingVirtualSegment && !isContinuingPastSegment && !continuePastBoundary) { // We've entered a new segment during normal playback logger.debug(`Entered a new segment during playback: ${segmentAtCurrentTime.id}, setting as active`); setActiveSegment(segmentAtCurrentTime); setSelectedSegmentId(segmentAtCurrentTime.id); setShowEmptySpaceTooltip(false); // Reset continuation flags to ensure boundary detection works for this new segment setContinuePastBoundary(false); sessionStorage.removeItem('continuingPastSegment'); } // If we're playing a virtual segment and enter a real segment, we've reached our boundary // We should stop playback if (isPlayingVirtualSegment && video && segmentAtCurrentTime) { logger.debug(`CUTAWAY BOUNDARY REACHED: Current position ${formatDetailedTime(video.currentTime)} at segment ${segmentAtCurrentTime.id} - STOPPING at boundary ${formatDetailedTime(segmentAtCurrentTime.startTime)}`); video.pause(); // Force exact time position with high precision and multiple attempts setTimeout(() => { if (videoRef.current) { // First seek directly to exact start time, no offset videoRef.current.currentTime = segmentAtCurrentTime.startTime; // Update UI immediately to match video position onSeek(segmentAtCurrentTime.startTime); // Also update tooltip time displays setDisplayTime(segmentAtCurrentTime.startTime); setClickedTime(segmentAtCurrentTime.startTime); // Reset continuePastBoundary when reaching a segment boundary setContinuePastBoundary(false); // Update tooltip to show segment tooltip at boundary setSelectedSegmentId(segmentAtCurrentTime.id); setShowEmptySpaceTooltip(false); // Force multiple adjustments to ensure exact precision const verifyPosition = () => { if (videoRef.current) { // Always force the exact time in every verification videoRef.current.currentTime = segmentAtCurrentTime.startTime; // Make sure we update the UI to reflect the corrected position onSeek(segmentAtCurrentTime.startTime); // Update the displayTime and clickedTime state to match exact position setDisplayTime(segmentAtCurrentTime.startTime); setClickedTime(segmentAtCurrentTime.startTime); logger.debug(`Position corrected to exact segment boundary: ${formatDetailedTime(videoRef.current.currentTime)} (target: ${formatDetailedTime(segmentAtCurrentTime.startTime)})`); } }; // Apply multiple correction attempts with increasing delays setTimeout(verifyPosition, 10); // Immediate correction setTimeout(verifyPosition, 20); // First correction setTimeout(verifyPosition, 50); // Second correction setTimeout(verifyPosition, 100); // Third correction setTimeout(verifyPosition, 200); // Final correction // Also add event listeners to ensure position is corrected whenever video state changes videoRef.current.addEventListener('seeked', verifyPosition); videoRef.current.addEventListener('canplay', verifyPosition); videoRef.current.addEventListener('waiting', verifyPosition); // Remove these event listeners after a short time setTimeout(() => { if (videoRef.current) { videoRef.current.removeEventListener('seeked', verifyPosition); videoRef.current.removeEventListener('canplay', verifyPosition); videoRef.current.removeEventListener('waiting', verifyPosition); } }, 300); } }, 10); setIsPlayingSegment(false); setActiveSegment(null); return; // Exit early, we've handled this case } // Only update active segment if we're not in "continue past segment" mode // or if we're in a virtual cutaway segment const continuingPastSegment = (activeSegment === null && isPlayingSegment === true) || isContinuingPastSegment || isPlayingVirtualSegment; if (continuingPastSegment) { // We're in the special case where we're continuing past a segment boundary // or playing a cutaway area // Just update the tooltip, but don't reactivate boundary checking if (selectedSegmentId !== segmentAtCurrentTime.id || showEmptySpaceTooltip) { logger.debug("Tooltip updated for segment during continued playback:", segmentAtCurrentTime.id, isPlayingVirtualSegment ? "(cutaway playback - keeping virtual segment)" : ""); setSelectedSegmentId(segmentAtCurrentTime.id); setShowEmptySpaceTooltip(false); // If we're in a different segment now, clear the continuation flag // but only if it's not the same segment we were in before // AND we're not playing a cutaway area if (!isPlayingVirtualSegment && sessionStorage.getItem('lastSegmentId') !== segmentAtCurrentTime.id.toString()) { logger.debug("Moved to a different segment - ending continuation mode"); sessionStorage.removeItem('continuingPastSegment'); } } } else { // Normal case - update both tooltip and active segment if (activeSegment?.id !== segmentAtCurrentTime.id || showEmptySpaceTooltip) { logger.debug("Playback moved into segment:", segmentAtCurrentTime.id); setSelectedSegmentId(segmentAtCurrentTime.id); setActiveSegment(segmentAtCurrentTime); setShowEmptySpaceTooltip(false); // Store the current segment ID for comparison later sessionStorage.setItem('lastSegmentId', segmentAtCurrentTime.id.toString()); } } } // If we're in empty space now else { // Check if we need to change the tooltip (we were in a segment before) if (activeSegment !== null || !showEmptySpaceTooltip) { logger.debug("Playback moved to empty space"); setSelectedSegmentId(null); setActiveSegment(null); // Calculate available space for new segment before showing tooltip const availableSpace = calculateAvailableSpace(currentTime); setAvailableSegmentDuration(availableSpace); // Show empty space tooltip if there's enough space if (availableSpace >= 0.5) { setShowEmptySpaceTooltip(true); logger.debug("Empty space with available duration:", availableSpace); } else { setShowEmptySpaceTooltip(false); } } } } else if (videoRef.current.paused && isPlayingSegment) { // When just paused from playing state, update display time to show the actual stopped position setDisplayTime(currentTime); setClickedTime(currentTime); setIsPlayingSegment(false); // Log the stopping point logger.debug("Video paused at:", formatDetailedTime(currentTime)); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentTime, isPlayingSegment, activeSegment, selectedSegmentId, clipSegments]); // Close zoom dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as HTMLElement; if (isZoomDropdownOpen && !target.closest('.zoom-dropdown-container')) { setIsZoomDropdownOpen(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, [isZoomDropdownOpen]); // Global click handler to close tooltips when clicking outside useEffect(() => { // Remove the global click handler that closes tooltips // This keeps the popup always visible, even when clicking outside the timeline // Keeping the dependency array to avoid linting errors return () => {}; }, [selectedSegmentId, showEmptySpaceTooltip, isPlayingSegment]); // Initialize drag handlers for trim handles useEffect(() => { const leftHandle = leftHandleRef.current; const rightHandle = rightHandleRef.current; const timeline = timelineRef.current; if (!leftHandle || !rightHandle || !timeline) return; const initDrag = (isLeft: boolean) => (e: MouseEvent) => { e.preventDefault(); const timelineRect = timeline.getBoundingClientRect(); let isDragging = true; let finalTime = isLeft ? trimStart : trimEnd; // Track the final time for history recording // Use custom events to indicate drag state const createCustomEvent = (type: string) => { return new CustomEvent('trim-handle-event', { detail: { type, isStart: isLeft } }); }; // Dispatch start drag event to signal not to record history during drag document.dispatchEvent(createCustomEvent('drag-start')); const onMouseMove = (moveEvent: MouseEvent) => { if (!isDragging) return; const timelineWidth = timelineRect.width; const position = Math.max(0, Math.min(1, (moveEvent.clientX - timelineRect.left) / timelineWidth)); const newTime = position * duration; // Store position globally for iOS Safari if (typeof window !== 'undefined') { window.lastSeekedPosition = newTime; } if (isLeft) { if (newTime < trimEnd) { // Don't record in history during drag - this avoids multiple history entries document.dispatchEvent(new CustomEvent('update-trim', { detail: { time: newTime, isStart: true, recordHistory: false } })); finalTime = newTime; } } else { if (newTime > trimStart) { // Don't record in history during drag - this avoids multiple history entries document.dispatchEvent(new CustomEvent('update-trim', { detail: { time: newTime, isStart: false, recordHistory: false } })); finalTime = newTime; } } }; const onMouseUp = () => { isDragging = false; document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); // Now record the final position in history with action type if (isLeft) { // Final update with history recording document.dispatchEvent(new CustomEvent('update-trim', { detail: { time: finalTime, isStart: true, recordHistory: true, action: 'adjust_trim_start' } })); } else { document.dispatchEvent(new CustomEvent('update-trim', { detail: { time: finalTime, isStart: false, recordHistory: true, action: 'adjust_trim_end' } })); } // Dispatch end drag event document.dispatchEvent(createCustomEvent('drag-end')); }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }; leftHandle.addEventListener('mousedown', initDrag(true)); rightHandle.addEventListener('mousedown', initDrag(false)); return () => { leftHandle.removeEventListener('mousedown', initDrag(true)); rightHandle.removeEventListener('mousedown', initDrag(false)); }; }, [duration, trimStart, trimEnd, onTrimStartChange, onTrimEndChange]); // Render solid color backgrounds evenly spread across timeline const renderThumbnails = () => { // Create thumbnail sections even if we don't have actual thumbnail data const numSections = thumbnails.length || 10; // Default to 10 sections if no thumbnails return Array.from({ length: numSections }).map((_, index) => { const segmentDuration = duration / numSections; const segmentStartTime = index * segmentDuration; const segmentEndTime = segmentStartTime + segmentDuration; const midpointTime = (segmentStartTime + segmentEndTime) / 2; // Get a solid color based on the segment position const backgroundColor = generateSolidColor(midpointTime, duration); return (
); }); }; // Render split points const renderSplitPoints = () => { return splitPoints.map((point, index) => { const pointPercent = (point / duration) * 100; return (
); }); }; // Helper function to calculate available space for a new segment const calculateAvailableSpace = (startTime: number): number => { // Always return at least 0.1 seconds to ensure tooltip shows const MIN_SPACE = 0.1; // Determine the amount of available space: // 1. Check remaining space until the end of video const remainingDuration = Math.max(0, duration - startTime); // 2. Find the next segment (if any) const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); // Find the next and previous segments const nextSegment = sortedSegments.find(seg => seg.startTime > startTime); const prevSegment = [...sortedSegments].reverse().find(seg => seg.endTime < startTime); // Calculate the actual available space let availableSpace; if (nextSegment) { // Space until next segment availableSpace = nextSegment.startTime - startTime; } else { // Space until end of video availableSpace = duration - startTime; } // Log the space calculation for debugging logger.debug("Space calculation:", { position: formatDetailedTime(startTime), nextSegment: nextSegment ? formatDetailedTime(nextSegment.startTime) : "none", prevSegment: prevSegment ? formatDetailedTime(prevSegment.endTime) : "none", availableSpace: formatDetailedTime(Math.max(MIN_SPACE, availableSpace)) }); // Always return at least MIN_SPACE to ensure tooltip shows return Math.max(MIN_SPACE, availableSpace); }; // Function to update tooltip based on current time position const updateTooltipForPosition = (currentPosition: number) => { if (!timelineRef.current) return; // Find if we're in a segment at the current position with a small tolerance const segmentAtPosition = clipSegments.find(seg => { const isWithinSegment = currentPosition >= seg.startTime && currentPosition <= seg.endTime; const isVeryCloseToStart = Math.abs(currentPosition - seg.startTime) < 0.001; const isVeryCloseToEnd = Math.abs(currentPosition - seg.endTime) < 0.001; return isWithinSegment || isVeryCloseToStart || isVeryCloseToEnd; }); // Find the next and previous segments const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); const nextSegment = sortedSegments.find(seg => seg.startTime > currentPosition); const prevSegment = [...sortedSegments].reverse().find(seg => seg.endTime < currentPosition); if (segmentAtPosition) { // We're in or exactly at a segment boundary setSelectedSegmentId(segmentAtPosition.id); setShowEmptySpaceTooltip(false); } else { // We're in a cutaway area // Calculate available space for new segment const availableSpace = calculateAvailableSpace(currentPosition); setAvailableSegmentDuration(availableSpace); // Always show empty space tooltip setSelectedSegmentId(null); setShowEmptySpaceTooltip(true); // Log position info for debugging logger.debug("Cutaway position:", { current: formatDetailedTime(currentPosition), prevSegmentEnd: prevSegment ? formatDetailedTime(prevSegment.endTime) : "none", nextSegmentStart: nextSegment ? formatDetailedTime(nextSegment.startTime) : "none", availableSpace: formatDetailedTime(availableSpace) }); } // Update tooltip position const rect = timelineRef.current.getBoundingClientRect(); const positionPercent = (currentPosition / duration) * 100; let xPos; if (zoomLevel > 1 && scrollContainerRef.current) { // For zoomed timeline, adjust for scroll position const visibleTimelineLeft = rect.left - scrollContainerRef.current.scrollLeft; xPos = visibleTimelineLeft + (rect.width * (positionPercent / 100)); } else { // For non-zoomed timeline, use simple calculation xPos = rect.left + (rect.width * (positionPercent / 100)); } setTooltipPosition({ x: xPos, y: rect.top - 10 }); }; // Handle timeline click to seek and show a tooltip const handleTimelineClick = (e: React.MouseEvent) => { if (!timelineRef.current || !scrollContainerRef.current) return; // If on mobile device and video hasn't been initialized, don't handle timeline clicks if (isIOSUninitialized) { // Don't do anything on timeline click if mobile device hasn't been initialized return; } // Check if video is globally playing before the click const wasPlaying = videoRef.current && !videoRef.current.paused; logger.debug("Video was playing before timeline click:", wasPlaying); // Reset continuation flag when clicking on timeline - ensures proper boundary detection setContinuePastBoundary(false); const rect = timelineRef.current.getBoundingClientRect(); // Account for scroll position when calculating the click position let position; if (zoomLevel > 1) { // When zoomed, we need to account for the scroll position const scrollLeft = scrollContainerRef.current.scrollLeft; const totalWidth = timelineRef.current.clientWidth; position = (e.clientX - rect.left + scrollLeft) / totalWidth; } else { // Normal calculation for 1x zoom position = (e.clientX - rect.left) / rect.width; } const newTime = position * duration; // Log the position for debugging logger.debug("Timeline clicked at:", formatDetailedTime(newTime), "distance from end:", formatDetailedTime(duration - newTime)); // Store position globally for iOS Safari (this is critical for first-time visits) if (typeof window !== 'undefined') { window.lastSeekedPosition = newTime; } // Seek to the clicked position immediately for all clicks onSeek(newTime); // Always update both clicked time and display time for tooltip actions setClickedTime(newTime); setDisplayTime(newTime); // Find if we clicked in a segment with a small tolerance for boundaries const segmentAtClickedTime = clipSegments.find(seg => { // Standard check for being inside a segment const isInside = newTime >= seg.startTime && newTime <= seg.endTime; // Additional checks for being exactly at the start or end boundary (with small tolerance) const isAtStart = Math.abs(newTime - seg.startTime) < 0.01; const isAtEnd = Math.abs(newTime - seg.endTime) < 0.01; return isInside || isAtStart || isAtEnd; }); // Handle active segment assignment for boundary checking if (segmentAtClickedTime) { setActiveSegment(segmentAtClickedTime); } // Resume playback in two cases: // 1. If it was playing before (regular playback) // 2. If we're in preview mode (regardless of previous playing state) if ((wasPlaying || isPreviewMode) && videoRef.current) { logger.debug("Resuming playback after timeline click"); videoRef.current.play() .then(() => { setIsPlayingSegment(true); logger.debug("Resumed playback after seeking"); }) .catch(err => { console.error("Error resuming playback:", err); setIsPlayingSegment(false); }); } // Only process tooltip display if clicked on the timeline background or thumbnails, not on other UI elements if (e.target === timelineRef.current || (e.target as HTMLElement).classList.contains('timeline-thumbnail')) { // Check if there's a segment at the clicked position if (segmentAtClickedTime) { setSelectedSegmentId(segmentAtClickedTime.id); setShowEmptySpaceTooltip(false); } else { // We're in a cutaway area - always show tooltip setSelectedSegmentId(null); // Calculate the available space for a new segment const availableSpace = calculateAvailableSpace(newTime); setAvailableSegmentDuration(availableSpace); // Calculate and set tooltip position correctly for zoomed timeline let xPos; if (zoomLevel > 1) { // For zoomed timeline, calculate the visible position const visibleTimelineLeft = rect.left - scrollContainerRef.current.scrollLeft; const clickPosPercent = newTime / duration; xPos = visibleTimelineLeft + (clickPosPercent * rect.width); } else { // For 1x zoom, use the client X xPos = e.clientX; } setTooltipPosition({ x: xPos, y: rect.top - 10 // Position tooltip above the timeline }); // Always show the empty space tooltip in cutaway areas setShowEmptySpaceTooltip(true); // Log the cutaway area details const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); const prevSegment = [...sortedSegments].reverse().find(seg => seg.endTime < newTime); const nextSegment = sortedSegments.find(seg => seg.startTime > newTime); logger.debug("Clicked in cutaway area:", { position: formatDetailedTime(newTime), availableSpace: formatDetailedTime(availableSpace), prevSegmentEnd: prevSegment ? formatDetailedTime(prevSegment.endTime) : "none", nextSegmentStart: nextSegment ? formatDetailedTime(nextSegment.startTime) : "none" }); } } }; // Handle segment resize - works with both mouse and touch events const handleSegmentResize = (segmentId: number, isLeft: boolean) => (e: React.MouseEvent | React.TouchEvent) => { e.preventDefault(); e.stopPropagation(); // Prevent triggering parent's events if (!timelineRef.current) return; const timelineRect = timelineRef.current.getBoundingClientRect(); const timelineWidth = timelineRect.width; // Find the segment that's being resized const segment = clipSegments.find(seg => seg.id === segmentId); if (!segment) return; const originalStartTime = segment.startTime; const originalEndTime = segment.endTime; // Store the original segment state to compare after dragging const segmentBeforeDrag = {...segment}; // Add a visual indicator that we're in resize mode (for mouse devices) document.body.style.cursor = 'ew-resize'; // Add a temporary overlay to help with dragging outside the element const overlay = document.createElement('div'); overlay.style.position = 'fixed'; overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.width = '100vw'; overlay.style.height = '100vh'; overlay.style.zIndex = '1000'; overlay.style.cursor = 'ew-resize'; document.body.appendChild(overlay); // Track dragging state and final positions let isDragging = true; let finalStartTime = originalStartTime; let finalEndTime = originalEndTime; // Dispatch an event to signal drag start document.dispatchEvent(new CustomEvent('segment-drag-start', { detail: { segmentId } })); // Keep the tooltip visible during drag // Function to handle both mouse and touch movements const handleDragMove = (clientX: number) => { if (!isDragging || !timelineRef.current) return; const updatedTimelineRect = timelineRef.current.getBoundingClientRect(); const position = Math.max(0, Math.min(1, (clientX - updatedTimelineRect.left) / updatedTimelineRect.width)); const newTime = position * duration; // Create a temporary segment with the current drag position to check against const draggedSegment = { id: segmentId, startTime: isLeft ? newTime : originalStartTime, endTime: isLeft ? originalEndTime : newTime, name: '', thumbnail: '' }; // Check if the current marker position intersects with where the segment will be const currentSegmentStart = isLeft ? newTime : originalStartTime; const currentSegmentEnd = isLeft ? originalEndTime : newTime; const isMarkerInSegment = currentTime >= currentSegmentStart && currentTime <= currentSegmentEnd; // Update tooltip based on marker intersection if (isMarkerInSegment) { // Show segment tooltip if marker is inside the segment setSelectedSegmentId(segmentId); setShowEmptySpaceTooltip(false); } else { // Show cutaway tooltip if marker is outside the segment setSelectedSegmentId(null); // Calculate available space for cutaway tooltip const availableSpace = calculateAvailableSpace(currentTime); setAvailableSegmentDuration(availableSpace); setShowEmptySpaceTooltip(true); } // Find neighboring segments (exclude the current one) const otherSegments = clipSegments.filter(seg => seg.id !== segmentId); // Calculate new start/end times based on drag direction let newStartTime = originalStartTime; let newEndTime = originalEndTime; if (isLeft) { // Dragging left handle - adjust start time newStartTime = Math.min(newTime, originalEndTime - 0.5); // Find the closest left neighbor const leftNeighbors = otherSegments .filter(seg => seg.endTime <= originalStartTime) .sort((a, b) => b.endTime - a.endTime); const leftNeighbor = leftNeighbors[0]; // Prevent overlapping with left neighbor if (leftNeighbor && newStartTime < leftNeighbor.endTime) { newStartTime = leftNeighbor.endTime; } // Snap to the nearest segment with a small threshold const snapThreshold = 0.3; // seconds if (leftNeighbor && Math.abs(newStartTime - leftNeighbor.endTime) < snapThreshold) { newStartTime = leftNeighbor.endTime; } // Update final value for history recording finalStartTime = newStartTime; } else { // Dragging right handle - adjust end time newEndTime = Math.max(newTime, originalStartTime + 0.5); // Find the closest right neighbor const rightNeighbors = otherSegments .filter(seg => seg.startTime >= originalEndTime) .sort((a, b) => a.startTime - b.startTime); const rightNeighbor = rightNeighbors[0]; // Prevent overlapping with right neighbor if (rightNeighbor && newEndTime > rightNeighbor.startTime) { newEndTime = rightNeighbor.startTime; } // Snap to the nearest segment with a small threshold const snapThreshold = 0.3; // seconds if (rightNeighbor && Math.abs(newEndTime - rightNeighbor.startTime) < snapThreshold) { newEndTime = rightNeighbor.startTime; } // Update final value for history recording finalEndTime = newEndTime; } // Create a new segments array with the updated segment const updatedSegments = clipSegments.map(seg => { if (seg.id === segmentId) { return { ...seg, startTime: newStartTime, endTime: newEndTime }; } return seg; }); // Create a custom event to update the segments WITHOUT recording in history during drag const updateEvent = new CustomEvent('update-segments', { detail: { segments: updatedSegments, recordHistory: false // Don't record intermediate states } }); document.dispatchEvent(updateEvent); // During dragging, check if the current tooltip needs to be updated based on segment position if (selectedSegmentId === segmentId && videoRef.current) { const currentTime = videoRef.current.currentTime; const segment = updatedSegments.find(seg => seg.id === segmentId); if (segment) { // Check if playhead position is now outside the segment after dragging const isInsideSegment = currentTime >= segment.startTime && currentTime <= segment.endTime; // Log the current position information for debugging logger.debug(`During drag - playhead at ${formatDetailedTime(currentTime)} is ${isInsideSegment ? 'inside' : 'outside'} segment (${formatDetailedTime(segment.startTime)} - ${formatDetailedTime(segment.endTime)})`); if (!isInsideSegment && isPlayingSegment) { logger.debug("Playhead position is outside segment after dragging - updating tooltip"); // Stop playback if we were playing and dragged the segment away from playhead videoRef.current.pause(); setIsPlayingSegment(false); setActiveSegment(null); } // Update display time to stay in bounds of the segment if (currentTime < segment.startTime) { logger.debug(`Adjusting display time to segment start: ${formatDetailedTime(segment.startTime)}`); setDisplayTime(segment.startTime); // Update UI state to reflect that playback will be from segment start setClickedTime(segment.startTime); } else if (currentTime > segment.endTime) { logger.debug(`Adjusting display time to segment end: ${formatDetailedTime(segment.endTime)}`); setDisplayTime(segment.endTime); // Update UI state to reflect that playback will be from segment end setClickedTime(segment.endTime); } } } }; // Function to handle the end of dragging (for both mouse and touch) const handleDragEnd = () => { if (!isDragging) return; isDragging = false; // Clean up event listeners for both mouse and touch document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('touchmove', handleTouchMove); document.removeEventListener('touchend', handleTouchEnd); document.removeEventListener('touchcancel', handleTouchEnd); // Reset styles document.body.style.cursor = ''; if (document.body.contains(overlay)) { document.body.removeChild(overlay); } // Record the final position in history as a single action const finalSegments = clipSegments.map(seg => { if (seg.id === segmentId) { return { ...seg, startTime: finalStartTime, endTime: finalEndTime }; } return seg; }); // Now we can create a history record for the complete drag operation const actionType = isLeft ? 'adjust_segment_start' : 'adjust_segment_end'; document.dispatchEvent(new CustomEvent('update-segments', { detail: { segments: finalSegments, recordHistory: true, action: actionType } })); // After drag is complete, do a final check to see if playhead is inside the segment if (selectedSegmentId === segmentId && videoRef.current) { const currentTime = videoRef.current.currentTime; const segment = finalSegments.find(seg => seg.id === segmentId); if (segment) { const isInsideSegment = currentTime >= segment.startTime && currentTime <= segment.endTime; logger.debug(`Drag complete - playhead at ${formatDetailedTime(currentTime)} is ${isInsideSegment ? 'inside' : 'outside'} segment (${formatDetailedTime(segment.startTime)} - ${formatDetailedTime(segment.endTime)})`); // Check if playhead status changed during drag const wasInsideSegmentBefore = currentTime >= segmentBeforeDrag.startTime && currentTime <= segmentBeforeDrag.endTime; logger.debug(`Playhead was ${wasInsideSegmentBefore ? 'inside' : 'outside'} segment before drag, now ${isInsideSegment ? 'inside' : 'outside'}`); // Update UI elements based on segment position if (!isInsideSegment) { // If we were playing and the playhead is now outside the segment, stop playback if (isPlayingSegment) { videoRef.current.pause(); setIsPlayingSegment(false); setActiveSegment(null); setContinuePastBoundary(false); logger.debug("Stopped playback because playhead is outside segment after drag completion"); } // Update display time to be within the segment's bounds if (currentTime < segment.startTime) { logger.debug(`Final adjustment - setting display time to segment start: ${formatDetailedTime(segment.startTime)}`); setDisplayTime(segment.startTime); setClickedTime(segment.startTime); } else if (currentTime > segment.endTime) { logger.debug(`Final adjustment - setting display time to segment end: ${formatDetailedTime(segment.endTime)}`); setDisplayTime(segment.endTime); setClickedTime(segment.endTime); } } // Special case: playhead was outside segment before, but now it's inside - can start playback else if (!wasInsideSegmentBefore && isInsideSegment) { logger.debug("Playhead moved INTO segment during drag - can start playback"); setActiveSegment(segment); // In preview mode, we automatically start playing when playhead enters segment if (isPreviewMode) { videoRef.current.play() .then(() => { setIsPlayingSegment(true); logger.debug("Started playback after dragging segment to include playhead"); }) .catch(err => { console.error("Error starting playback:", err); }); } } // Another special case: playhead was inside segment before, but now is also inside but at a different position else if (wasInsideSegmentBefore && isInsideSegment && (segment.startTime !== segmentBeforeDrag.startTime || segment.endTime !== segmentBeforeDrag.endTime)) { logger.debug("Segment boundaries changed while playhead remained inside - updating activeSegment"); // Update the active segment reference to ensure boundary detection works with new bounds setActiveSegment(segment); } } } }; // Mouse-specific event handlers const handleMouseMove = (moveEvent: MouseEvent) => { handleDragMove(moveEvent.clientX); }; const handleMouseUp = () => { handleDragEnd(); }; // Touch-specific event handlers const handleTouchMove = (moveEvent: TouchEvent) => { if (moveEvent.touches.length > 0) { moveEvent.preventDefault(); // Prevent scrolling while dragging handleDragMove(moveEvent.touches[0].clientX); } }; const handleTouchEnd = () => { handleDragEnd(); }; // Register event listeners for both mouse and touch document.addEventListener('mousemove', handleMouseMove, { passive: false }); document.addEventListener('mouseup', handleMouseUp); document.addEventListener('touchmove', handleTouchMove, { passive: false }); document.addEventListener('touchend', handleTouchEnd); document.addEventListener('touchcancel', handleTouchEnd); }; // Handle segment click to show the tooltip const handleSegmentClick = (segmentId: number) => (e: React.MouseEvent) => { // Don't show tooltip if clicked on handle if ((e.target as HTMLElement).classList.contains('clip-segment-handle')) { return; } e.preventDefault(); e.stopPropagation(); logger.debug("Segment clicked:", segmentId); // Reset continuation flag when selecting a segment - ensures proper boundary detection setContinuePastBoundary(false); // Check if video is currently playing before clicking const wasPlaying = videoRef.current && !videoRef.current.paused; logger.debug("seekVideo: Was playing before:", wasPlaying); // Set the current segment as selected setSelectedSegmentId(segmentId); // Find the segment in our data const segment = clipSegments.find(seg => seg.id === segmentId); if (!segment) return; // Find the segment element in the DOM const segmentElement = e.currentTarget as HTMLElement; const segmentRect = segmentElement.getBoundingClientRect(); // Calculate relative click position within the segment (0 to 1) const relativeX = (e.clientX - segmentRect.left) / segmentRect.width; // Convert to time based on segment's start and end times const clickTime = segment.startTime + (relativeX * (segment.endTime - segment.startTime)); // Ensure time is within segment bounds const boundedTime = Math.max(segment.startTime, Math.min(segment.endTime, clickTime)); // Set both clicked time and display time for UI setClickedTime(boundedTime); setDisplayTime(boundedTime); // Check if the video's current time is inside or outside the segment // This helps with updating the tooltip correctly after dragging operations if (videoRef.current) { const currentVideoTime = videoRef.current.currentTime; const isPlayheadInsideSegment = currentVideoTime >= segment.startTime && currentVideoTime <= segment.endTime; logger.debug(`Segment click - playhead at ${formatDetailedTime(currentVideoTime)} is ${isPlayheadInsideSegment ? 'inside' : 'outside'} segment (${formatDetailedTime(segment.startTime)} - ${formatDetailedTime(segment.endTime)})`); // If playhead is outside the segment, update the display time to segment boundary if (!isPlayheadInsideSegment) { // Adjust the display time based on which end is closer to the playhead if (Math.abs(currentVideoTime - segment.startTime) < Math.abs(currentVideoTime - segment.endTime)) { // Playhead is closer to segment start logger.debug(`Playhead outside segment - adjusting to segment start: ${formatDetailedTime(segment.startTime)}`); setDisplayTime(segment.startTime); // Don't update clickedTime here since we already set it to the clicked position } else { // Playhead is closer to segment end logger.debug(`Playhead outside segment - adjusting to segment end: ${formatDetailedTime(segment.endTime)}`); setDisplayTime(segment.endTime); // Don't update clickedTime here since we already set it to the clicked position } } } // Seek to this position (this will update the video's current time) onSeek(boundedTime); // If video was playing before OR we're in preview mode, ensure it continues playing if ((wasPlaying || isPreviewMode) && videoRef.current) { // Set current segment as active segment for boundary checking setActiveSegment(segment); // Reset the continuePastBoundary flag when clicking on a segment to ensure boundaries work setContinuePastBoundary(false); // Continue playing from the new position videoRef.current.play() .then(() => { setIsPlayingSegment(true); logger.debug("Continued preview playback after segment click"); }) .catch(err => { console.error("Error resuming playback after segment click:", err); }); } // Always continue playback in preview mode, even if video was paused when clicking if (isPreviewMode && videoRef.current) { setActiveSegment(segment); videoRef.current.play() .then(() => { setIsPlayingSegment(true); logger.debug("Continued preview playback after segment click"); }) .catch(err => { console.error("Error continuing preview playback:", err); }); } // Calculate tooltip position directly above click point const tooltipX = e.clientX; const tooltipY = segmentRect.top - 10; setTooltipPosition({ x: tooltipX, y: tooltipY }); // Auto-scroll to center the clicked position for zoomed timeline if (zoomLevel > 1 && timelineRef.current && scrollContainerRef.current) { const timelineRect = timelineRef.current.getBoundingClientRect(); const timelineWidth = timelineRef.current.clientWidth; const containerWidth = scrollContainerRef.current.clientWidth; // Calculate pixel position of clicked time const clickedPosPixel = (boundedTime / duration) * timelineWidth; // Center the view on the clicked position const targetScrollLeft = Math.max(0, clickedPosPixel - (containerWidth / 2)); // Smooth scroll to the clicked point scrollContainerRef.current.scrollTo({ left: targetScrollLeft, behavior: 'smooth' }); // Update tooltip position after scrolling completes setTimeout(() => { if (timelineRef.current && scrollContainerRef.current) { // Calculate new position based on viewport const updatedRect = timelineRef.current.getBoundingClientRect(); const timePercent = boundedTime / duration; const newPosition = (timePercent * timelineWidth) - scrollContainerRef.current.scrollLeft + updatedRect.left; setTooltipPosition({ x: newPosition, y: tooltipY }); } }, 300); // Wait for smooth scrolling to complete } // We no longer need a local click handler as we have a global one // that handles closing tooltips when clicking outside }; // Show tooltip for the segment const setShowTooltip = (show: boolean, segmentId: number, x: number, y: number) => { setSelectedSegmentId(show ? segmentId : null); setTooltipPosition({ x, y }); }; // Render the clip segments on the timeline const renderClipSegments = () => { return clipSegments.map((segment, index) => { const startPercent = (segment.startTime / duration) * 100; const widthPercent = ((segment.endTime - segment.startTime) / duration) * 100; // Generate a solid background color based on segment position const backgroundColor = generateSolidColor( (segment.startTime + segment.endTime) / 2, duration ); return (
Segment {index + 1}
{formatTime(segment.startTime)} - {formatTime(segment.endTime)}
Duration: {formatTime(segment.endTime - segment.startTime)}
{/* Resize handles with both mouse and touch support */}
{ e.stopPropagation(); handleSegmentResize(segment.id, true)(e); }} onTouchStart={(e) => { e.stopPropagation(); handleSegmentResize(segment.id, true)(e); }} >
{ e.stopPropagation(); handleSegmentResize(segment.id, false)(e); }} onTouchStart={(e) => { e.stopPropagation(); handleSegmentResize(segment.id, false)(e); }} >
); }); }; // Add a new useEffect hook to listen for segment deletion events useEffect(() => { const handleSegmentDelete = (event: CustomEvent) => { const { segmentId } = event.detail; // Check if this was the last segment before deletion const remainingSegments = clipSegments.filter(seg => seg.id !== segmentId); if (remainingSegments.length === 0) { // Create a full video segment const fullVideoSegment: Segment = { id: Date.now(), name: 'Full Video', startTime: 0, endTime: duration, thumbnail: '' }; // Create and dispatch the update event to replace all segments with the full video segment const updateEvent = new CustomEvent('update-segments', { detail: { segments: [fullVideoSegment], recordHistory: true, action: 'create_full_video_segment' } }); document.dispatchEvent(updateEvent); // Update UI to show the segment tooltip setSelectedSegmentId(fullVideoSegment.id); setShowEmptySpaceTooltip(false); setClickedTime(currentTime); setDisplayTime(currentTime); setActiveSegment(fullVideoSegment); // Calculate tooltip position at current time if (timelineRef.current) { const rect = timelineRef.current.getBoundingClientRect(); const posPercent = (currentTime / duration) * 100; const xPosition = rect.left + (rect.width * (posPercent / 100)); setTooltipPosition({ x: xPosition, y: rect.top - 10 }); logger.debug("Created full video segment:", { id: fullVideoSegment.id, duration: formatDetailedTime(duration), currentPosition: formatDetailedTime(currentTime) }); } } else if (selectedSegmentId === segmentId) { // Handle normal segment deletion const deletedSegment = clipSegments.find(seg => seg.id === segmentId); if (!deletedSegment) return; // Calculate available space after deletion const availableSpace = calculateAvailableSpace(currentTime); // Update UI to show cutaway tooltip setSelectedSegmentId(null); setShowEmptySpaceTooltip(true); setAvailableSegmentDuration(availableSpace); // Calculate tooltip position if (timelineRef.current) { const rect = timelineRef.current.getBoundingClientRect(); const posPercent = (currentTime / duration) * 100; const xPosition = rect.left + (rect.width * (posPercent / 100)); setTooltipPosition({ x: xPosition, y: rect.top - 10 }); logger.debug("Segment deleted, showing cutaway tooltip:", { position: formatDetailedTime(currentTime), availableSpace: formatDetailedTime(availableSpace) }); } } }; // Add event listener for the custom delete-segment event document.addEventListener('delete-segment', handleSegmentDelete as EventListener); // Clean up event listener on component unmount return () => { document.removeEventListener('delete-segment', handleSegmentDelete as EventListener); }; }, [selectedSegmentId, clipSegments, currentTime, duration, timelineRef]); // Add an effect to synchronize tooltip play state with video play state useEffect(() => { const video = videoRef.current; if (!video) return; const handlePlay = () => { if (!videoRef.current) return; const video = videoRef.current; const currentPosition = video.currentTime; // Reset continuePastBoundary flag when starting new playback setContinuePastBoundary(false); // Find the next stopping point based on current position let stopTime = duration; let currentSegment = null; let nextSegment = null; // First, check if we're inside a segment with high precision currentSegment = clipSegments.find(seg => { const isWithinSegment = currentPosition >= seg.startTime && currentPosition <= seg.endTime; const isAtExactStart = Math.abs(currentPosition - seg.startTime) < 0.001; // Within 1ms of start const isAtExactEnd = Math.abs(currentPosition - seg.endTime) < 0.001; // Within 1ms of end return isWithinSegment || isAtExactStart || isAtExactEnd; }); // Find the next segment with high precision nextSegment = clipSegments .filter(seg => { const isAfterCurrent = seg.startTime > currentPosition; const isNotAtExactPosition = Math.abs(seg.startTime - currentPosition) > 0.001; return isAfterCurrent && isNotAtExactPosition; }) .sort((a, b) => a.startTime - b.startTime)[0]; // Determine where to stop based on position if (currentSegment) { // If we're in a segment, stop at its end stopTime = currentSegment.endTime; setActiveSegment(currentSegment); } else if (nextSegment) { // If we're in a cutaway and there's a next segment, stop at its start stopTime = nextSegment.startTime; // Don't set active segment since we're in a cutaway } // Create a boundary checker function with high precision const checkBoundary = () => { if (!video) return; const currentPosition = video.currentTime; const timeLeft = stopTime - currentPosition; // If we're approaching the boundary (within 1ms) or have passed it if (timeLeft <= 0.001 || currentPosition >= stopTime) { // First pause playback video.pause(); // Force exact position with multiple verification attempts const setExactPosition = () => { if (!video) return; // Set to exact boundary time video.currentTime = stopTime; onSeek(stopTime); setDisplayTime(stopTime); setClickedTime(stopTime); logger.debug("Position verification:", { target: formatDetailedTime(stopTime), actual: formatDetailedTime(video.currentTime), difference: Math.abs(video.currentTime - stopTime).toFixed(3) }); }; // Multiple attempts to ensure precision setExactPosition(); setTimeout(setExactPosition, 10); setTimeout(setExactPosition, 20); setTimeout(setExactPosition, 50); // Update UI based on where we stopped if (currentSegment) { setSelectedSegmentId(currentSegment.id); setShowEmptySpaceTooltip(false); } else if (nextSegment) { setSelectedSegmentId(nextSegment.id); setShowEmptySpaceTooltip(false); setActiveSegment(nextSegment); } else { setSelectedSegmentId(null); setShowEmptySpaceTooltip(true); setActiveSegment(null); } // Remove our boundary checker video.removeEventListener('timeupdate', checkBoundary); setIsPlaying(false); setIsPlayingSegment(false); // Reset continuePastBoundary flag when stopping at boundary setContinuePastBoundary(false); return; } }; // Start our boundary checker video.addEventListener('timeupdate', checkBoundary); // Start playing video.play() .then(() => { setIsPlaying(true); setIsPlayingSegment(true); logger.debug("Playback started:", { from: formatDetailedTime(currentPosition), to: formatDetailedTime(stopTime), currentSegment: currentSegment ? `Segment ${currentSegment.id}` : 'None', nextSegment: nextSegment ? `Segment ${nextSegment.id}` : 'None' }); }) .catch(err => { console.error("Error playing video:", err); }); }; const handlePause = () => { logger.debug("Video paused from external control"); setIsPlayingSegment(false); }; video.addEventListener('play', handlePlay); video.addEventListener('pause', handlePause); return () => { video.removeEventListener('play', handlePlay); video.removeEventListener('pause', handlePause); }; }, [clipSegments, duration, onSeek]); // Handle mouse movement over timeline to remember position const handleTimelineMouseMove = (e: React.MouseEvent) => { if (!timelineRef.current) return; const rect = timelineRef.current.getBoundingClientRect(); const position = (e.clientX - rect.left) / rect.width; const time = position * duration; // Ensure time is within bounds const boundedTime = Math.max(0, Math.min(duration, time)); // Store position globally for iOS Safari if (typeof window !== 'undefined') { window.lastSeekedPosition = boundedTime; } }; // Add the dragging state and handlers to the component // Inside the TimelineControls component, add these new state variables const [isDragging, setIsDragging] = useState(false); // Add a dragging ref to track state without relying on React's state updates const isDraggingRef = useRef(false); // Add drag handlers to enable dragging the timeline marker const startDrag = (e: React.MouseEvent | React.TouchEvent) => { // If on mobile device and video hasn't been initialized, don't allow dragging if (isIOSUninitialized) { return; } e.stopPropagation(); // Don't trigger the timeline click e.preventDefault(); // Prevent text selection during drag setIsDragging(true); isDraggingRef.current = true; // Use ref for immediate value access // Show tooltip immediately when starting to drag updateTooltipForPosition(currentTime); // Handle mouse events const handleMouseMove = (moveEvent: MouseEvent) => { if (!timelineRef.current || !scrollContainerRef.current) return; // Calculate the position based on mouse or touch coordinates const rect = timelineRef.current.getBoundingClientRect(); let position; if (zoomLevel > 1) { // When zoomed, account for scroll position const scrollLeft = scrollContainerRef.current.scrollLeft; const totalWidth = timelineRef.current.clientWidth; position = (moveEvent.clientX - rect.left + scrollLeft) / totalWidth; } else { // Normal calculation for 1x zoom position = (moveEvent.clientX - rect.left) / rect.width; } // Constrain position between 0 and 1 position = Math.max(0, Math.min(1, position)); // Convert to time and seek const newTime = position * duration; // Update both clicked time and display time setClickedTime(newTime); setDisplayTime(newTime); // Update tooltip state based on new position updateTooltipForPosition(newTime); // Store position globally for iOS Safari if (typeof window !== 'undefined') { (window as any).lastSeekedPosition = newTime; } // Seek to the new position onSeek(newTime); }; // Handle mouse up to stop dragging const handleMouseUp = () => { setIsDragging(false); isDraggingRef.current = false; // Update ref immediately document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; // Add event listeners to track movement and release document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }; // Handle touch events for mobile devices const startTouchDrag = (e: React.TouchEvent) => { // If on mobile device and video hasn't been initialized, don't allow dragging if (isIOSUninitialized) { return; } e.stopPropagation(); // Don't trigger the timeline click e.preventDefault(); // Prevent text selection during drag setIsDragging(true); isDraggingRef.current = true; // Use ref for immediate value access // Show tooltip immediately when starting to drag updateTooltipForPosition(currentTime); // Handle touch move events const handleTouchMove = (moveEvent: TouchEvent) => { if (!timelineRef.current || !scrollContainerRef.current || !moveEvent.touches[0]) return; // Calculate the position based on touch coordinates const rect = timelineRef.current.getBoundingClientRect(); let position; if (zoomLevel > 1) { // When zoomed, account for scroll position const scrollLeft = scrollContainerRef.current.scrollLeft; const totalWidth = timelineRef.current.clientWidth; position = (moveEvent.touches[0].clientX - rect.left + scrollLeft) / totalWidth; } else { // Normal calculation for 1x zoom position = (moveEvent.touches[0].clientX - rect.left) / rect.width; } // Constrain position between 0 and 1 position = Math.max(0, Math.min(1, position)); // Convert to time and seek const newTime = position * duration; // Update both clicked time and display time setClickedTime(newTime); setDisplayTime(newTime); // Update tooltip state based on new position updateTooltipForPosition(newTime); // Store position globally for mobile browsers if (typeof window !== 'undefined') { (window as any).lastSeekedPosition = newTime; } // Seek to the new position onSeek(newTime); }; // Handle touch end to stop dragging const handleTouchEnd = () => { setIsDragging(false); isDraggingRef.current = false; // Update ref immediately document.removeEventListener('touchmove', handleTouchMove); document.removeEventListener('touchend', handleTouchEnd); document.removeEventListener('touchcancel', handleTouchEnd); }; // Add event listeners to track movement and release document.addEventListener('touchmove', handleTouchMove, { passive: false }); document.addEventListener('touchend', handleTouchEnd); document.addEventListener('touchcancel', handleTouchEnd); }; // Add a useEffect to log the redirect URL whenever it changes useEffect(() => { if (redirectUrl) { logger.debug('Redirect URL updated:', { redirectUrl, saveType, isSuccessModalOpen: showSuccessModal }); } }, [redirectUrl, saveType, showSuccessModal]); // Add a useEffect for auto-redirection useEffect(() => { let countdownInterval: NodeJS.Timeout; let redirectTimeout: NodeJS.Timeout; if (showSuccessModal && redirectUrl) { // Start countdown timer let secondsLeft = 10; // Update the countdown every second countdownInterval = setInterval(() => { secondsLeft--; const countdownElement = document.querySelector('.countdown'); if (countdownElement) { countdownElement.textContent = secondsLeft.toString(); } if (secondsLeft <= 0) { clearInterval(countdownInterval); } }, 1000); // Set redirect timeout redirectTimeout = setTimeout(() => { // Reset unsaved changes flag before navigating away if (onSave) onSave(); // Redirect to the URL logger.debug('Automatically redirecting to:', redirectUrl); window.location.href = redirectUrl; }, 10000); // 10 seconds } // Cleanup on unmount or when success modal closes return () => { if (countdownInterval) clearInterval(countdownInterval); if (redirectTimeout) clearTimeout(redirectTimeout); }; }, [showSuccessModal, redirectUrl, onSave]); return (
{/* Current Timecode with Milliseconds */}
Timeline
{/* Current time display removed as requested */}
Total Segments: {formatDetailedTime(clipSegments.reduce((sum, segment) => sum + (segment.endTime - segment.startTime), 0))}
{/* Timeline Container with Scrollable Wrapper */}
1 ? 'auto' : 'hidden' }}>
{/* Current Position Marker */}
{/* Top circle for popup toggle */}
{ // Prevent event propagation to avoid triggering the timeline container click e.stopPropagation(); // For ensuring accurate segment detection, refresh clipSegments first // This helps when clicking right after creating a new segment const refreshedSegmentAtCurrentTime = clipSegments.find( seg => currentTime >= seg.startTime && currentTime <= seg.endTime ); // Toggle tooltip visibility with a single click if (selectedSegmentId || showEmptySpaceTooltip) { // When tooltip is open and - icon is clicked, simply close the tooltips logger.debug("Closing tooltip"); setSelectedSegmentId(null); setShowEmptySpaceTooltip(false); // Don't reopen the tooltip - just leave it closed return; } else { // Use our improved tooltip position logic updateTooltipForPosition(currentTime); logger.debug("Opening tooltip at:", formatDetailedTime(currentTime)); } }} > {selectedSegmentId || showEmptySpaceTooltip ? '-' : '+'}
{/* Bottom circle for dragging */}
â‹®
{/* Trim Line Markers - hidden when segments exist */} {clipSegments.length === 0 && ( <>
)} {/* Clip Segments */} {renderClipSegments()} {/* Split Points */} {renderSplitPoints()} {/* Thumbnails */} {renderThumbnails()} {/* Segment Tooltip */} {selectedSegmentId !== null && (
{/* First row with time adjustment buttons */}
{formatDetailedTime(displayTime)}
{/* Second row with action buttons */}
{/* */} {/* Play/Pause button for empty space - Same as main play/pause button */}
)} {/* Empty space tooltip - positioned absolutely within timeline container */} {showEmptySpaceTooltip && selectedSegmentId === null && (
{/* First row with time adjustment buttons - same as segment tooltip */}
{formatDetailedTime(clickedTime)}
{/* Second row with action buttons similar to segment tooltip */}
{/* New segment button - Moved to first position */} {availableSegmentDuration >= 0.5 && ( )} {/* Go to start button - play from beginning of cutaway (until next segment) */} {/* Play/Pause button for empty space */} {/* */} {/* Play/Pause button for empty space - Same as main play/pause button */} {/* Segment end adjustment button (always shown) */} {/* Segment start adjustment button (always shown) */}
)}
{/* Precise Time Navigation & Zoom Controls */}
{/* Precise Time Input */}
Go to Time:
{ if (e.key === 'Enter') { const input = e.currentTarget.value; try { // Parse time format like "00:30:15.250" or "30:15.250" or "30:15" const parts = input.split(':'); let hours = 0, minutes = 0, seconds = 0, milliseconds = 0; if (parts.length === 3) { // Format: HH:MM:SS.ms hours = parseInt(parts[0]); minutes = parseInt(parts[1]); const secParts = parts[2].split('.'); seconds = parseInt(secParts[0]); if (secParts.length > 1) milliseconds = parseInt(secParts[1].padEnd(3, '0').substring(0, 3)); } else if (parts.length === 2) { // Format: MM:SS.ms minutes = parseInt(parts[0]); const secParts = parts[1].split('.'); seconds = parseInt(secParts[0]); if (secParts.length > 1) milliseconds = parseInt(secParts[1].padEnd(3, '0').substring(0, 3)); } const totalSeconds = hours * 3600 + minutes * 60 + seconds + milliseconds / 1000; if (!isNaN(totalSeconds) && totalSeconds >= 0 && totalSeconds <= duration) { onSeek(totalSeconds); // Create a helper function to show tooltip that uses the same logic as the millisecond buttons const showTooltipAtTime = (timeInSeconds: number) => { // Find the segment at the given time using improved matching const segmentAtTime = clipSegments.find(seg => { const isWithinSegment = timeInSeconds >= seg.startTime && timeInSeconds <= seg.endTime; const isAtExactStart = Math.abs(timeInSeconds - seg.startTime) < 0.001; // Within 1ms of start const isAtExactEnd = Math.abs(timeInSeconds - seg.endTime) < 0.001; // Within 1ms of end return isWithinSegment || isAtExactStart || isAtExactEnd; }); // Calculate position for tooltip if (timelineRef.current && scrollContainerRef.current) { const rect = timelineRef.current.getBoundingClientRect(); // Handle zoomed timeline by accounting for scroll position let xPos; if (zoomLevel > 1) { // For zoomed timeline, calculate position based on visible area const visibleTimelineLeft = rect.left - scrollContainerRef.current.scrollLeft; const markerVisibleX = visibleTimelineLeft + ((timeInSeconds / duration) * rect.width); xPos = markerVisibleX; } else { // For non-zoomed timeline, use the simple calculation const positionPercent = (timeInSeconds / duration); xPos = rect.left + (rect.width * positionPercent); } setTooltipPosition({ x: xPos, y: rect.top - 10 }); setClickedTime(timeInSeconds); if (segmentAtTime) { // Show segment tooltip setSelectedSegmentId(segmentAtTime.id); setShowEmptySpaceTooltip(false); } else { // Show empty space tooltip setSelectedSegmentId(null); setShowEmptySpaceTooltip(true); } } }; // Show tooltip after a slight delay to ensure UI updates setTimeout(() => showTooltipAtTime(totalSeconds), 10); } } catch (error) { console.error("Invalid time format:", error); } } }} /> {/* Helper function to show tooltip at current position */} {/* This is defined within the component to access state variables and functions */}
{(() => { // Helper function to show the appropriate tooltip at the current time position const showTooltipAtCurrentTime = () => { // Find the segment at the current time (after seeking) - using improved matching for better precision const segmentAtCurrentTime = clipSegments.find(seg => { const isWithinSegment = currentTime >= seg.startTime && currentTime <= seg.endTime; const isAtExactStart = Math.abs(currentTime - seg.startTime) < 0.001; // Within 1ms of start const isAtExactEnd = Math.abs(currentTime - seg.endTime) < 0.001; // Within 1ms of end return isWithinSegment || isAtExactStart || isAtExactEnd; }); // Calculate position for tooltip (above the timeline where the marker is) if (timelineRef.current && scrollContainerRef.current) { const rect = timelineRef.current.getBoundingClientRect(); // Handle zoomed timeline by accounting for scroll position let xPos; if (zoomLevel > 1) { // For zoomed timeline, calculate position based on visible area const visibleTimelineLeft = rect.left - scrollContainerRef.current.scrollLeft; const markerVisibleX = visibleTimelineLeft + ((currentTime / duration) * rect.width); xPos = markerVisibleX; } else { // For non-zoomed timeline, use the simple calculation const positionPercent = (currentTime / duration); xPos = rect.left + (rect.width * positionPercent); } setTooltipPosition({ x: xPos, y: rect.top - 10 }); setClickedTime(currentTime); if (segmentAtCurrentTime) { // Show segment tooltip setSelectedSegmentId(segmentAtCurrentTime.id); setShowEmptySpaceTooltip(false); } else { // Calculate available space for new segment before showing tooltip const availableSpace = calculateAvailableSpace(currentTime); setAvailableSegmentDuration(availableSpace); // Only show tooltip if there's enough space for a minimal segment if (availableSpace >= 0.5) { // Show empty space tooltip setSelectedSegmentId(null); setShowEmptySpaceTooltip(true); } else { // Not enough space, don't show any tooltip setSelectedSegmentId(null); setShowEmptySpaceTooltip(false); } } } }; return ( <> ); })()}
{/* Zoom Dropdown Control and Save Buttons */}
{isZoomDropdownOpen && (
{[1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096].map(level => (
{ onZoomChange(level); setIsZoomDropdownOpen(false); }} > {zoomLevel === level && ( )} Zoom {level}x
))}
)}
{/* Save Buttons Row */}
{onSave && ( )} {onSaveACopy && ( )} {onSaveSegments && ( )}
{/* Save Confirmation Modal */} setShowSaveModal(false)} title="Save Changes" actions={ <> } >

You're about to save these changes and replace the original video. This action cannot be undone.

The original video will be replaced with this trimmed version.

{/* Save As Copy Modal */} setShowSaveAsModal(false)} title="Save As New Copy" actions={ <> } >

You're about to save these changes as a new copy. Your original video will remain unchanged.

A new copy of the video will be created with your trimmed segments.

{/* Processing Modal */} {}} title="Processing Video" >

Please wait while your video is being processed...

{/* Save Segments Modal */} setShowSaveSegmentsModal(false)} title="Save Segments" actions={ <> } >

You're about to save each segment as a separate video file. There are {clipSegments.length} segments to be saved.

Each segment will be saved with its name as the filename.

{/* Success Modal */} setShowSuccessModal(false)} title="Video Edited Successfully" >
{/*

{successMessage || "Processing completed successfully!"}

*/}

{saveType === "segments" ? "You will be redirected to your " : "You will be redirected to your "} media page {" in "} 10 seconds. {' '} {saveType === "segments" ? "The new video(s) will soon be there." : "Changes to the video might take a few minutes to be applied."}

{/* Error Modal */} setShowErrorModal(false)} title="Video Processing Error" >

{errorMessage}

{/* Dropdown was moved inside the container element */}
{/* Mobile Uninitialized Overlay - Show only when on mobile and video hasn't been played yet */} {isIOSUninitialized && (

Please play the video first to enable timeline controls

)}
); }; export default TimelineControls;