import { useRef, useEffect, useState, useCallback } from 'react'; import { formatTime, formatDetailedTime } from '../lib/timeUtils'; import { generateThumbnail, generateSolidColor } from '../lib/videoUtils'; import { Segment } from './ClipSegments'; import Modal from './Modal'; import { autoSaveVideo } 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[]; selectedSegmentId?: number | null; onSelectedSegmentChange?: (segmentId: number | null) => void; onSegmentUpdate?: (segmentId: number, updates: Partial) => void; onChapterSave?: (chapters: { name: string; from: string; to: string }[]) => void; onTrimStartChange: (time: number) => void; onTrimEndChange: (time: number) => void; onZoomChange: (level: number) => void; onSeek: (time: number) => void; videoRef: React.RefObject; hasUnsavedChanges?: boolean; isIOSUninitialized?: boolean; isPlaying: boolean; setIsPlaying: (playing: boolean) => void; onPlayPause: () => void; isPlayingSegments?: boolean; } // Function to calculate and constrain tooltip position to keep it on screen // Uses smooth transitions instead of hard breakpoints to eliminate jumping const constrainTooltipPosition = (positionPercent: number) => { // Smooth transition zones instead of hard breakpoints const leftTransitionStart = 0; const leftTransitionEnd = 25; const rightTransitionStart = 75; const rightTransitionEnd = 100; let leftValue: string; let transform: string; if (positionPercent <= leftTransitionEnd) { // Left side: smooth transition from center to left-aligned if (positionPercent <= leftTransitionStart) { // Fully left-aligned leftValue = '0%'; transform = 'none'; } else { // Smooth transition zone const transitionProgress = (positionPercent - leftTransitionStart) / (leftTransitionEnd - leftTransitionStart); const translateAmount = -50 * transitionProgress; // Gradually reduce from 0% to -50% leftValue = `${positionPercent}%`; transform = `translateX(${translateAmount}%)`; } } else if (positionPercent >= rightTransitionStart) { // Right side: smooth transition from center to right-aligned if (positionPercent >= rightTransitionEnd) { // Fully right-aligned leftValue = '100%'; transform = 'translateX(-100%)'; } else { // Smooth transition zone const transitionProgress = (positionPercent - rightTransitionStart) / (rightTransitionEnd - rightTransitionStart); const translateAmount = -50 - 50 * transitionProgress; // Gradually change from -50% to -100% leftValue = `${positionPercent}%`; transform = `translateX(${translateAmount}%)`; } } else { // Center zone: normal centered positioning leftValue = `${positionPercent}%`; transform = 'translateX(-50%)'; } return { left: leftValue, transform }; }; const TimelineControls = ({ currentTime, duration, thumbnails, trimStart, trimEnd, splitPoints, zoomLevel, clipSegments, selectedSegmentId: externalSelectedSegmentId, onSelectedSegmentChange, onSegmentUpdate, onChapterSave, onTrimStartChange, onTrimEndChange, onZoomChange, onSeek, videoRef, hasUnsavedChanges = false, isIOSUninitialized = false, isPlaying, setIsPlaying, onPlayPause, // Add this prop isPlayingSegments = false, }: TimelineControlsProps) => { const timelineRef = useRef(null); const leftHandleRef = useRef(null); const rightHandleRef = useRef(null); // Use external selectedSegmentId if provided, otherwise use internal state const [internalSelectedSegmentId, setInternalSelectedSegmentId] = useState(null); const selectedSegmentId = externalSelectedSegmentId !== undefined ? externalSelectedSegmentId : internalSelectedSegmentId; const setSelectedSegmentId = (segmentId: number | null) => { if (onSelectedSegmentChange) { onSelectedSegmentChange(segmentId); } else { setInternalSelectedSegmentId(segmentId); } }; 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); // Chapter editor state const [editingChapterTitle, setEditingChapterTitle] = useState(''); const [chapterHasUnsavedChanges, setChapterHasUnsavedChanges] = useState(false); // Sort segments by startTime for chapter editor const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); const selectedSegment = sortedSegments.find((seg) => seg.id === selectedSegmentId); // Auto-save related state const [lastAutoSaveTime, setLastAutoSaveTime] = useState(''); const [isAutoSaving, setIsAutoSaving] = useState(false); const autoSaveTimerRef = useRef(null); const clipSegmentsRef = useRef(clipSegments); // Keep clipSegmentsRef updated useEffect(() => { clipSegmentsRef.current = clipSegments; }, [clipSegments]); // Auto-save function const performAutoSave = useCallback(async () => { try { setIsAutoSaving(true); // Format segments data for API request - use ref to get latest segments const segments = clipSegmentsRef.current.map((segment) => ({ startTime: formatDetailedTime(segment.startTime), endTime: formatDetailedTime(segment.endTime), name: segment.name, chapterTitle: segment.chapterTitle, text: segment.chapterTitle, })); logger.debug('segments', segments); const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null; // For testing, use '1234' if no mediaId is available const finalMediaId = mediaId || '1234'; logger.debug('mediaId', finalMediaId); if (!finalMediaId || segments.length === 0) { logger.debug('No mediaId or segments, skipping auto-save'); setIsAutoSaving(false); return; } logger.debug('Auto-saving segments:', { mediaId: finalMediaId, segments }); const response = await autoSaveVideo(finalMediaId, { segments }); if (response.success) { logger.debug('Auto-save successful'); // Format the timestamp for display const date = new Date(response.timestamp); const formattedTime = date .toLocaleString('en-US', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }) .replace(',', ''); setLastAutoSaveTime(formattedTime); logger.debug('Auto-save successful:', formattedTime); } else { logger.error('Auto-save failed:', response.error); } } catch (error) { logger.error('Auto-save error:', error); } finally { setIsAutoSaving(false); } }, []); // Schedule auto-save with debounce const scheduleAutoSave = useCallback(() => { // Clear any existing timer if (autoSaveTimerRef.current) { clearTimeout(autoSaveTimerRef.current); logger.debug('Cleared existing auto-save timer'); } logger.debug('Scheduling new auto-save in 1 second...'); // Schedule new auto-save after 1 second of inactivity const timerId = setTimeout(() => { logger.debug('Auto-save timer fired! Calling performAutoSave...'); performAutoSave(); }, 1000); autoSaveTimerRef.current = timerId; logger.debug('Timer ID set:', timerId); }, [performAutoSave]); // Update editing title when selected segment changes useEffect(() => { if (selectedSegment) { setEditingChapterTitle(selectedSegment.chapterTitle || ''); } else { setEditingChapterTitle(''); } }, [selectedSegmentId, selectedSegment]); // Handle chapter title change const handleChapterTitleChange = (value: string) => { setEditingChapterTitle(value); setChapterHasUnsavedChanges(true); // Update the segment immediately if (selectedSegmentId && onSegmentUpdate) { onSegmentUpdate(selectedSegmentId, { chapterTitle: value }); } }; // Handle save chapters const handleSaveChapters = () => { if (!onChapterSave) return; // Convert segments to chapter format const chapters = sortedSegments.map((segment, index) => ({ name: segment.chapterTitle || `Chapter ${index + 1}`, from: formatDetailedTime(segment.startTime), to: formatDetailedTime(segment.endTime), })); onChapterSave(chapters); setChapterHasUnsavedChanges(false); }; // 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 [showSaveChaptersModal, setShowSaveChaptersModal] = 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<'chapters'>('chapters'); // 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 Chapters handler const handleSaveChaptersConfirm = async () => { // Close confirmation modal and show processing modal setShowSaveChaptersModal(false); setShowProcessingModal(true); setSaveType('chapters'); try { // Format chapters data for API request const chapters = clipSegments .filter((segment) => segment.chapterTitle && segment.chapterTitle.trim()) .map((segment) => ({ name: segment.chapterTitle || `Chapter ${segment.id}`, from: formatDetailedTime(segment.startTime), to: formatDetailedTime(segment.endTime), })); if (chapters.length === 0) { setErrorMessage('No chapters with titles found'); setShowErrorModal(true); setShowProcessingModal(false); return; } // Call the onChapterSave function if provided if (onChapterSave) { await onChapterSave(chapters); setShowProcessingModal(false); setSuccessMessage('Chapters saved successfully!'); // Set redirect URL to media page const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null; if (mediaId) { setRedirectUrl(`/view?m=${mediaId}`); } setShowSuccessModal(true); } else { setErrorMessage('Chapter save function not available'); setShowErrorModal(true); setShowProcessingModal(false); } } catch (error) { logger.error('Error saving chapters:', error); setShowProcessingModal(false); // Set error message and show error modal const errorMsg = error instanceof Error ? error.message : 'An error occurred while saving chapters'; logger.debug('Save chapters 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(() => { // Skip if no video or no active segment const video = videoRef.current; if (!video || !activeSegment || !isPlayingSegment) { // Log why we're skipping if (!video) logger.debug('Skipping segment boundary check - no video element'); else if (!activeSegment) logger.debug('Skipping segment boundary check - no active segment'); else if (!isPlayingSegment) logger.debug('Skipping segment boundary check - not in segment playback mode'); return; } // Skip boundary checking when playing all segments if (isPlayingSegments) { logger.debug('Skipping segment boundary check during segments playback'); 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, 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]); // Listen for segment updates and trigger auto-save useEffect(() => { const handleSegmentUpdate = (event: CustomEvent) => { const { recordHistory, fromAutoSave } = event.detail; logger.debug('handleSegmentUpdate called, recordHistory:', recordHistory, 'fromAutoSave:', fromAutoSave); // Only auto-save when history is recorded and not loading from auto-save if (recordHistory && !fromAutoSave) { logger.debug('Calling scheduleAutoSave from handleSegmentUpdate'); scheduleAutoSave(); } }; const handleSegmentDragEnd = () => { // Trigger auto-save when drag operations end scheduleAutoSave(); }; const handleTrimUpdate = (event: CustomEvent) => { const { recordHistory } = event.detail; // Only auto-save when history is recorded (i.e., after trim operations complete) if (recordHistory) { scheduleAutoSave(); } }; document.addEventListener('update-segments', handleSegmentUpdate as EventListener); document.addEventListener('segment-drag-end', handleSegmentDragEnd); document.addEventListener('update-trim', handleTrimUpdate as EventListener); document.addEventListener('delete-segment', scheduleAutoSave); document.addEventListener('split-segment', scheduleAutoSave); return () => { logger.debug('Cleaning up auto-save event listeners...'); document.removeEventListener('update-segments', handleSegmentUpdate as EventListener); document.removeEventListener('segment-drag-end', handleSegmentDragEnd); document.removeEventListener('update-trim', handleTrimUpdate as EventListener); document.removeEventListener('delete-segment', scheduleAutoSave); document.removeEventListener('split-segment', scheduleAutoSave); // Clear any pending auto-save timer if (autoSaveTimerRef.current) { logger.debug('Clearing auto-save timer in cleanup:', autoSaveTimerRef.current); clearTimeout(autoSaveTimerRef.current); } }; }, [scheduleAutoSave]); // Perform initial auto-save when component mounts with segments useEffect(() => { if (clipSegments.length > 0 && !lastAutoSaveTime) { // Perform initial auto-save after a short delay setTimeout(() => { performAutoSave(); }, 500); } }, [lastAutoSaveTime, performAutoSave]); // Load saved segments from MEDIA_DATA on component mount useEffect(() => { const loadSavedSegments = () => { // Get savedSegments directly from window.MEDIA_DATA let savedData = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.savedSegments) || null; // If no saved segments, use default segments if (!savedData) { logger.debug('No saved segments found in MEDIA_DATA, using default segments'); savedData = { segments: [ { startTime: '00:00:00.000', endTime: '00:00:10.000', chapterTitle: 'Chapter 1 (from saved data)', }, { startTime: '00:00:12.000', endTime: '00:00:17.000', chapterTitle: 'Chapter 2 (from saved data)', }, { startTime: '00:00:20.000', endTime: '00:00:30.000', chapterTitle: 'Chapter 3 (from saved data)', }, ], updated_at: '2025-06-24 14:59:14', }; } logger.debug('Loading saved segments:', savedData); try { if (savedData && savedData.segments && savedData.segments.length > 0) { logger.debug('Found saved segments:', savedData); // Convert the saved segments to the format expected by the component const convertedSegments: Segment[] = savedData.segments.map((seg: any, index: number) => ({ id: Date.now() + index, // Generate unique IDs name: seg.name || `Segment ${index + 1}`, startTime: parseTimeString(seg.startTime), endTime: parseTimeString(seg.endTime), thumbnail: '', chapterTitle: seg.chapterTitle || '', // Preserve chapter title from saved data })); // Dispatch event to update segments const updateEvent = new CustomEvent('update-segments', { detail: { segments: convertedSegments, recordHistory: false, // Don't record loading saved segments in history fromAutoSave: true, }, }); document.dispatchEvent(updateEvent); // Update the last auto-save time if (savedData.updated_at) { const date = new Date(savedData.updated_at); const formattedTime = date .toLocaleString('en-US', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }) .replace(',', ''); setLastAutoSaveTime(formattedTime); } } else { logger.debug('No saved segments found'); } } catch (error) { console.error('Error loading saved segments:', error); } }; // Helper function to parse time string "HH:MM:SS.mmm" to seconds const parseTimeString = (timeStr: string): number => { const parts = timeStr.split(':'); if (parts.length !== 3) return 0; const hours = parseInt(parts[0]) || 0; const minutes = parseInt(parts[1]) || 0; const secondsParts = parts[2].split('.'); const seconds = parseInt(secondsParts[0]) || 0; const milliseconds = parseInt(secondsParts[1]) || 0; return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000; }; // Load saved segments after a short delay to ensure component is ready setTimeout(loadSavedSegments, 100); }, []); // Run only once on mount // 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) => { // Remove the check that prevents interaction during preview mode // This allows users to click and jump in the timeline while previewing if (!timelineRef.current || !scrollContainerRef.current) return; // If on mobile device and video hasn't been initialized, don't handle timeline clicks if (isIOSUninitialized) { 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 based on the current mode if (videoRef.current) { // Special handling for segments playback mode if (isPlayingSegments && wasPlaying) { // Update the current segment index if we clicked into a segment if (segmentAtClickedTime) { const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); const targetSegmentIndex = orderedSegments.findIndex((seg) => seg.id === segmentAtClickedTime.id); if (targetSegmentIndex !== -1) { // Dispatch a custom event to update the current segment index const updateSegmentIndexEvent = new CustomEvent('update-segment-index', { detail: { segmentIndex: targetSegmentIndex }, }); document.dispatchEvent(updateSegmentIndexEvent); logger.debug( `Segments playback mode: updating segment index to ${targetSegmentIndex} for timeline click in segment ${segmentAtClickedTime.id}` ); } } logger.debug('Segments playback mode: resuming playback after timeline click'); videoRef.current .play() .then(() => { setIsPlayingSegment(true); logger.debug('Resumed segments playback after timeline seeking'); }) .catch((err) => { console.error('Error resuming segments playback:', err); setIsPlayingSegment(false); }); } // Resume playback if it was playing before (but not during segments playback) else if (wasPlaying && !isPlayingSegments) { 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) => { // Remove the check that prevents interaction during preview mode // This allows users to resize segments while previewing 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); } // 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) => { // Remove the check that prevents interaction during preview mode // This allows users to click segments while previewing // 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); // Handle playback continuation based on the current mode if (videoRef.current) { // Special handling for segments playback mode if (isPlayingSegments && wasPlaying) { // Update the current segment index for segments playback mode const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); const targetSegmentIndex = orderedSegments.findIndex((seg) => seg.id === segmentId); if (targetSegmentIndex !== -1) { // Dispatch a custom event to update the current segment index const updateSegmentIndexEvent = new CustomEvent('update-segment-index', { detail: { segmentIndex: targetSegmentIndex }, }); document.dispatchEvent(updateSegmentIndexEvent); logger.debug( `Segments playback mode: updating segment index to ${targetSegmentIndex} for segment ${segmentId}` ); } // In segments playback mode, we want to continue the segments playback from the new position // The segments playback will naturally handle continuing to the next segments logger.debug('Segments playback mode: continuing playback from new position'); videoRef.current .play() .then(() => { setIsPlayingSegment(true); logger.debug('Continued segments playback after segment click'); }) .catch((err) => { console.error('Error continuing segments playback after segment click:', err); }); } // If video was playing before, ensure it continues playing (but not in segments mode) else if (wasPlaying && !isPlayingSegments) { // 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); }); } } // 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 */} {isPlayingSegments ? null : ( <>
{ 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(() => { // 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]); // Effect to handle redirect after success modal is closed useEffect(() => { if (!showSuccessModal && redirectUrl) { logger.debug('Redirecting to:', redirectUrl); window.location.href = redirectUrl; } }, [redirectUrl, saveType, showSuccessModal]); return (
{/* Current Timecode with Milliseconds */}
Timeline
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 */} {isPlayingSegments ? null : (
â‹®
)}
{/* Trim Line Markers - hidden when segments exist */} {clipSegments.length === 0 && ( <>
)} {/* Clip Segments */} {renderClipSegments()} {/* Split Points */} {renderSplitPoints()} {/* Thumbnails */} {renderThumbnails()} {/* Segment Tooltip */} {selectedSegmentId !== null && (
{ if (isPlayingSegments) { e.stopPropagation(); e.preventDefault(); } }} > {/* Chapter Editor for this segment */} {selectedSegmentId && (