2025-05-22 03:50:30 +03:00

4103 lines
181 KiB
TypeScript

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<HTMLVideoElement>;
onSave?: () => void;
onSaveACopy?: () => void;
onSaveSegments?: () => void;
isPreviewMode?: boolean;
hasUnsavedChanges?: boolean;
isIOSUninitialized?: boolean;
isPlaying: boolean;
setIsPlaying: (playing: boolean) => void;
onPlayPause: () => void; // Add this prop
isPlayingSegments?: boolean;
}
// 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
isPlayingSegments = false,
}: TimelineControlsProps) => {
const timelineRef = useRef<HTMLDivElement>(null);
const leftHandleRef = useRef<HTMLDivElement>(null);
const rightHandleRef = useRef<HTMLDivElement>(null);
const [selectedSegmentId, setSelectedSegmentId] = useState<number | null>(null);
const [showEmptySpaceTooltip, setShowEmptySpaceTooltip] = useState(false);
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
const [clickedTime, setClickedTime] = useState<number>(0);
const [isZoomDropdownOpen, setIsZoomDropdownOpen] = useState(false);
const [availableSegmentDuration, setAvailableSegmentDuration] = useState<number>(30); // Default 30 seconds
const [isPlayingSegment, setIsPlayingSegment] = useState(false);
const [activeSegment, setActiveSegment] = useState<Segment | null>(null);
const [displayTime, setDisplayTime] = useState<number>(0);
// Track when we should continue playing (clicking play after boundary stop)
const [continuePastBoundary, setContinuePastBoundary] = useState<boolean>(false);
// Reference for the scrollable container
const scrollContainerRef = useRef<HTMLDivElement>(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 (
<div
key={index}
className="timeline-thumbnail"
style={{
width: `${100 / numSections}%`,
backgroundColor: backgroundColor,
// Remove background image and use solid color instead
}}
/>
);
});
};
// Render split points
const renderSplitPoints = () => {
return splitPoints.map((point, index) => {
const pointPercent = (point / duration) * 100;
return (
<div
key={index}
className="split-point"
style={{ left: `${pointPercent}%` }}
></div>
);
});
};
// 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<HTMLDivElement>) => {
// Prevent interaction if segments are playing
if (isPlayingSegments) return;
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 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) => {
// Prevent interaction if segments are playing
if (isPlayingSegments) return;
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) => {
// Prevent interaction if segments are playing
if (isPlayingSegments) return;
// 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 (
<div
key={segment.id}
className={`clip-segment ${selectedSegmentId === segment.id ? 'selected' : ''}`}
style={{
left: `${startPercent}%`,
width: `${widthPercent}%`,
backgroundColor: backgroundColor,
borderWidth: '2px', // Make borders more visible
borderStyle: 'solid',
borderColor: 'rgba(0, 0, 0, 0.5)' // Darker border for better visibility
}}
onClick={handleSegmentClick(segment.id)}
>
<div className="clip-segment-info">
<div className="clip-segment-name">Segment {index + 1}</div>
<div className="clip-segment-time">{formatTime(segment.startTime)} - {formatTime(segment.endTime)}</div>
<div className="clip-segment-duration">Duration: {formatTime(segment.endTime - segment.startTime)}</div>
</div>
{/* Resize handles with both mouse and touch support */}
<div
className="clip-segment-handle left"
title="Resize segment start"
onMouseDown={(e) => {
e.stopPropagation();
handleSegmentResize(segment.id, true)(e);
}}
onTouchStart={(e) => {
e.stopPropagation();
handleSegmentResize(segment.id, true)(e);
}}
></div>
<div
className="clip-segment-handle right"
title="Resize segment end"
onMouseDown={(e) => {
e.stopPropagation();
handleSegmentResize(segment.id, false)(e);
}}
onTouchStart={(e) => {
e.stopPropagation();
handleSegmentResize(segment.id, false)(e);
}}
></div>
</div>
);
});
};
// 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<HTMLDivElement>) => {
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 (
<div className={`timeline-container-card ${isPlayingSegments ? 'segments-playback-mode' : ''}`}>
{/* Current Timecode with Milliseconds */}
<div className="timeline-header">
<div className="timeline-title">
<span className="timeline-title-text">Timeline</span>
</div>
{/* Current time display removed as requested */}
<div className="duration-time">
Total Segments: <span>{formatDetailedTime(clipSegments.reduce((sum, segment) => sum + (segment.endTime - segment.startTime), 0))}</span>
</div>
</div>
{/* Timeline Container with Scrollable Wrapper */}
<div
ref={scrollContainerRef}
className={`timeline-scroll-container ${isPlayingSegments ? 'segments-playback-mode' : ''}`}
style={{
overflow: zoomLevel > 1 ? 'auto' : 'hidden'
}}
>
<div
ref={timelineRef}
className="timeline-container"
onClick={handleTimelineClick}
onMouseMove={handleTimelineMouseMove}
style={{
width: `${zoomLevel * 100}%`,
cursor: 'pointer'
}}
>
{/* Current Position Marker */}
<div
className="timeline-marker"
style={{ left: `${currentTimePercent}%` }}
>
{/* Top circle for popup toggle */}
<div
className="timeline-marker-head"
onClick={(e) => {
// 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));
}
}}
>
<span className="timeline-marker-head-icon">
{selectedSegmentId || showEmptySpaceTooltip ? '-' : '+'}
</span>
</div>
{/* Bottom circle for dragging */}
<div
className={`timeline-marker-drag ${isDragging ? 'dragging' : ''}`}
onMouseDown={startDrag}
onTouchStart={startTouchDrag}
>
<span className="timeline-marker-drag-icon"></span>
</div>
</div>
{/* Trim Line Markers - hidden when segments exist */}
{clipSegments.length === 0 && (
<>
<div
className="trim-line-marker"
style={{ left: `${trimStartPercent}%` }}
>
<div ref={leftHandleRef} className="trim-handle left"></div>
</div>
<div
className="trim-line-marker"
style={{ left: `${trimEndPercent}%` }}
>
<div ref={rightHandleRef} className="trim-handle right"></div>
</div>
</>
)}
{/* Clip Segments */}
{renderClipSegments()}
{/* Split Points */}
{renderSplitPoints()}
{/* Thumbnails */}
{renderThumbnails()}
{/* Segment Tooltip */}
{selectedSegmentId !== null && (
<div
className={`segment-tooltip two-row-tooltip ${isPlayingSegments ? 'segments-playback-mode' : ''}`}
style={{
position: 'absolute',
...constrainTooltipPosition(currentTimePercent)
}}
>
{/* First row with time adjustment buttons */}
<div className="tooltip-row">
<button
className="tooltip-time-btn"
data-tooltip="Decrease by 50ms (hold for continuous adjustment)"
{...handleContinuousTimeAdjustment(-0.05)}
style={{
userSelect: 'none',
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none',
touchAction: 'manipulation',
cursor: 'pointer',
WebkitTapHighlightColor: 'transparent'
}}
>
-50ms
</button>
<div className="tooltip-time-display">{formatDetailedTime(displayTime)}</div>
<button
className="tooltip-time-btn"
data-tooltip="Increase by 50ms (hold for continuous adjustment)"
{...handleContinuousTimeAdjustment(0.05)}
style={{
userSelect: 'none',
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none',
touchAction: 'manipulation',
cursor: 'pointer',
WebkitTapHighlightColor: 'transparent'
}}
>
+50ms
</button>
</div>
{/* Second row with action buttons */}
<div className="tooltip-row tooltip-actions">
<button
className="tooltip-action-btn delete"
data-tooltip="Delete segment"
onClick={(e) => {
e.stopPropagation();
// Call the delete segment function with the current segment ID
const deleteEvent = new CustomEvent('delete-segment', {
detail: {
segmentId: selectedSegmentId
}
});
document.dispatchEvent(deleteEvent);
// We don't need to manually close the tooltip - our event handler will take care of updating the UI
}}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
<button
className="tooltip-action-btn scissors"
data-tooltip="Split segment at current position"
onClick={(e) => {
e.stopPropagation();
// Call the split segment function with the current segment ID and time
const splitEvent = new CustomEvent('split-segment', {
detail: {
segmentId: selectedSegmentId,
time: clickedTime
}
});
document.dispatchEvent(splitEvent);
// Keep the tooltip open
// setSelectedSegmentId(null);
}}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="6" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<line x1="20" y1="4" x2="8.12" y2="15.88" />
<line x1="14.47" y1="14.48" x2="20" y2="20" />
<line x1="8.12" y1="8.12" x2="12" y2="12" />
</svg>
</button>
<button
className="tooltip-action-btn play-from-start"
data-tooltip="Play segment from beginning"
onClick={(e) => {
e.stopPropagation();
// Find the selected segment
const segment = clipSegments.find(seg => seg.id === selectedSegmentId);
if (segment && videoRef.current) {
// Enable continuePastBoundary flag when user explicitly clicks play
// This will allow playback to continue even if we're at segment boundary
setContinuePastBoundary(true);
logger.debug("Setting continuePastBoundary=true to allow playback through boundaries");
// Special handling for when we're at the end of the segment already
// Check if we're at or extremely close to the end boundary
if (Math.abs(videoRef.current.currentTime - segment.endTime) < 0.05) {
logger.debug(`Already at end boundary (${formatDetailedTime(videoRef.current.currentTime)}), nudging position back slightly`);
const newPosition = Math.max(segment.startTime, segment.endTime - 0.1); // Move 100ms back from end
videoRef.current.currentTime = newPosition;
onSeek(newPosition);
setClickedTime(newPosition);
logger.debug(`Position adjusted to ${formatDetailedTime(newPosition)}`);
} else {
// Normal case - just seek to the start of the segment
onSeek(segment.startTime);
setClickedTime(segment.startTime);
}
// Set active segment for boundary checking before playing
setActiveSegment(segment);
// Start playing from the beginning of the segment with proper promise handling
videoRef.current.play()
.then(() => {
setIsPlayingSegment(true);
logger.debug("Playing from beginning of segment");
})
.catch(err => {
console.error("Error playing from beginning:", err);
});
}
// Don't close the tooltip
}}
>
<img src={playFromBeginningIcon} alt="Play from beginning" style={{width: '24px', height: '24px'}} />
</button>
{/* <button
className={`tooltip-action-btn ${isPlaying ? 'pause' : 'play'}`}
data-tooltip={isPlaying ? "Pause playback" : "Play from current position"}
onClick={(e) => {
e.stopPropagation();
// Find the current segment
const currentSegment = clipSegments.find(seg =>
currentTime >= seg.startTime && currentTime <= seg.endTime
);
if (isPlaying) {
// If playing, just pause
if (videoRef.current) {
videoRef.current.pause();
setIsPlayingSegment(false);
setContinuePastBoundary(false);
}
} else {
// If starting playback, set the active segment
if (currentSegment) {
setActiveSegment(currentSegment);
}
// Reset continuation flag when starting new playback
setContinuePastBoundary(false);
if (videoRef.current) {
videoRef.current.play()
.then(() => {
setIsPlayingSegment(true);
})
.catch(err => {
console.error("Error playing video:", err);
setIsPlayingSegment(false);
});
}
}
}}
>
{isPlaying ? (
<img src={pauseIcon} alt="Pause" style={{width: '24px', height: '24px'}} />
) : (
<img src={playIcon} alt="Play" style={{width: '24px', height: '24px'}} />
)}
</button> */}
{/* Play/Pause button for empty space - Same as main play/pause button */}
<button
className={`tooltip-action-btn ${isPlaying ? 'pause' : 'play'}`}
data-tooltip={isPlaying ? "Pause playback" : "Play from current position"}
onClick={(e) => {
e.stopPropagation();
if (isPlaying) {
// If playing, just pause
if (videoRef.current) {
videoRef.current.pause();
setIsPlayingSegment(false);
setContinuePastBoundary(false);
}
} else {
onPlayPause();
}
}}
>
{isPlaying ? (
<img src={pauseIcon} alt="Pause" style={{width: '24px', height: '24px'}} />
) : (
<img src={playIcon} alt="Play" style={{width: '24px', height: '24px'}} />
)}
</button>
<button
className="tooltip-action-btn set-in"
data-tooltip="Set start point at current position"
onClick={(e) => {
e.stopPropagation();
// Find the selected segment and update its start time
const segment = clipSegments.find(seg => seg.id === selectedSegmentId);
if (segment) {
// Create updated segments with new start time for selected segment
const updatedSegments = clipSegments.map(seg => {
if (seg.id === selectedSegmentId) {
return {
...seg,
startTime: clickedTime < seg.endTime - 0.5 ? clickedTime : seg.endTime - 0.5
};
}
return seg;
});
// Create and dispatch the update event
const updateEvent = new CustomEvent('update-segments', {
detail: {
segments: updatedSegments,
recordHistory: true, // Ensure this specific action is recorded in history
action: 'adjust_start_time'
}
});
document.dispatchEvent(updateEvent);
logger.debug("Set in clicked");
}
// Keep tooltip open
// setSelectedSegmentId(null);
}}
>
<img src={segmentStartIcon} alt="Set start point" style={{width: '24px', height: '24px'}} />
</button>
<button
className="tooltip-action-btn set-out"
data-tooltip="Set end point at current position"
onClick={(e) => {
e.stopPropagation();
// Find the selected segment and update its end time
const segment = clipSegments.find(seg => seg.id === selectedSegmentId);
if (segment) {
// Create updated segments with new end time for selected segment
const updatedSegments = clipSegments.map(seg => {
if (seg.id === selectedSegmentId) {
return {
...seg,
endTime: clickedTime > seg.startTime + 0.5 ? clickedTime : seg.startTime + 0.5
};
}
return seg;
});
// Create and dispatch the update event
const updateEvent = new CustomEvent('update-segments', {
detail: {
segments: updatedSegments,
recordHistory: true, // Ensure this specific action is recorded in history
action: 'adjust_end_time'
}
});
document.dispatchEvent(updateEvent);
logger.debug("Set out clicked");
}
// Keep the tooltip open
// setSelectedSegmentId(null);
}}
>
<img src={segmentEndIcon} alt="Set end point" style={{width: '24px', height: '24px'}} />
</button>
</div>
</div>
)}
{/* Empty space tooltip - positioned absolutely within timeline container */}
{showEmptySpaceTooltip && selectedSegmentId === null && (
<div
className={`empty-space-tooltip two-row-tooltip ${isPlayingSegments ? 'segments-playback-mode' : ''}`}
style={{
position: 'absolute',
...constrainTooltipPosition(currentTimePercent)
}}
>
{/* First row with time adjustment buttons - same as segment tooltip */}
<div className="tooltip-row">
<button
className="tooltip-time-btn"
data-tooltip="Decrease by 50ms (hold for continuous adjustment)"
{...handleContinuousTimeAdjustment(-0.05)}
>
-50ms
</button>
<div className="tooltip-time-display">{formatDetailedTime(clickedTime)}</div>
<button
className="tooltip-time-btn"
data-tooltip="Increase by 50ms (hold for continuous adjustment)"
{...handleContinuousTimeAdjustment(0.05)}
>
+50ms
</button>
</div>
{/* Second row with action buttons similar to segment tooltip */}
<div className="tooltip-row tooltip-actions">
{/* New segment button - Moved to first position */}
{availableSegmentDuration >= 0.5 && (
<button
className="tooltip-action-btn new-segment"
data-tooltip={`Create new segment`}
onClick={async (e) => {
e.stopPropagation();
// Create a new segment with the calculated available duration
const segmentStartTime = clickedTime;
const segmentEndTime = segmentStartTime + availableSegmentDuration;
// Only create if we have at least 0.5 seconds of space
if (availableSegmentDuration < 0.5) {
// Not enough space, close tooltip
setShowEmptySpaceTooltip(false);
return;
}
// Create the new segment with a generic name
const newSegment: Segment = {
id: Date.now(),
name: `segment`,
startTime: segmentStartTime,
endTime: segmentEndTime,
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
};
// Add the new segment to existing segments
const updatedSegments = [...clipSegments, newSegment];
// Create and dispatch the update event
const updateEvent = new CustomEvent('update-segments', {
detail: {
segments: updatedSegments,
recordHistory: true, // Explicitly record this action in history
action: 'create_segment'
}
});
document.dispatchEvent(updateEvent);
// Close empty space tooltip
setShowEmptySpaceTooltip(false);
// After creating the segment, wait a short time for the state to update
setTimeout(() => {
// The newly created segment is the last one in the array with the ID we just assigned
const createdSegment = updatedSegments[updatedSegments.length - 1];
if (createdSegment) {
// Set this segment as selected to show its tooltip
setSelectedSegmentId(createdSegment.id);
logger.debug("Created and selected new segment:", createdSegment.id);
}
}, 100); // Small delay to ensure state is updated
}}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="12" y1="8" x2="12" y2="16"></line>
<line x1="8" y1="12" x2="16" y2="12"></line>
</svg>
<span className="tooltip-btn-text">
New
</span>
</button>
)}
{/* Go to start button - play from beginning of cutaway (until next segment) */}
<button
className="tooltip-action-btn play-from-start"
data-tooltip="Play from beginning of cutaway"
onClick={(e) => {
e.stopPropagation();
if (videoRef.current) {
// Find cutaway boundaries (current position is somewhere in the cutaway)
const currentTime = clickedTime;
// Enable continuePastBoundary flag when user explicitly clicks play
// This will allow playback to continue even if we're at segment boundary
setContinuePastBoundary(true);
logger.debug("Setting continuePastBoundary=true to allow playback through boundaries");
// For start, find the previous segment's end or use video start (0)
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
// Find the previous segment (one that ends before the current time)
const previousSegment = [...sortedSegments].reverse().find(seg => seg.endTime < currentTime);
// Start from either previous segment end or beginning of video
// Add a small offset (0.025 second = 25ms) to ensure we're definitely past the segment boundary
const startTime = previousSegment ? (previousSegment.endTime + 0.025) : 0;
// For end, find the next segment after the current position
// Since we're looking for the boundary of this empty space, we need to find the
// segment that starts after our current position
const nextSegment = sortedSegments.find(seg => seg.startTime > currentTime);
// Define end boundary (either next segment start or video end)
const endTime = nextSegment ? nextSegment.startTime : duration;
// Create a virtual "segment" for the cutaway area
const cutawaySegment: Segment = {
id: -999, // Use a unique negative ID to indicate a virtual segment
name: "Cutaway",
startTime: startTime,
endTime: endTime,
thumbnail: ""
};
// Seek to the start of the cutaway (true beginning of this cutaway area)
onSeek(startTime);
setClickedTime(startTime);
// IMPORTANT: First reset isPlayingSegment to false to ensure clean state
setIsPlayingSegment(false);
// Then set active segment for boundary checking
// We use setTimeout to ensure this happens in the next tick
// after the isPlayingSegment value is updated
setTimeout(() => {
setActiveSegment(cutawaySegment);
}, 0);
// Add a manual boundary check specifically for cutaway playback
// This ensures we detect when we reach the next segment's start
const checkCutawayBoundary = () => {
if (!videoRef.current) return;
// Check if we've entered a segment (i.e., reached a boundary)
const currentPosition = videoRef.current.currentTime;
const segments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
// Find the next segment we're approaching - use a wider detection range
// to catch the boundary earlier
const nextSegment = segments.find(seg => seg.startTime > currentPosition - 0.3);
// We need to detect boundaries much earlier to allow for time to react
// This is a key fix - we need to detect the boundary BEFORE we reach it
// But don't stop if we're in continuePastBoundary mode
const shouldStop = nextSegment &&
(currentPosition >= nextSegment.startTime - 0.25) &&
(currentPosition <= nextSegment.startTime + 0.1) &&
!continuePastBoundary;
// Add logging to show boundary check decisions
if (nextSegment && (currentPosition >= nextSegment.startTime - 0.25) &&
(currentPosition <= nextSegment.startTime + 0.1)) {
logger.debug(`Approaching boundary at ${formatDetailedTime(nextSegment.startTime)}, continuePastBoundary=${continuePastBoundary}, willStop=${shouldStop}`);
}
// Also check if we've entered a different segment - we need to detect this too
const segmentAtCurrentTime = segments.find(
seg => currentPosition >= seg.startTime && currentPosition <= seg.endTime
);
// If we've moved directly into a segment during playback, we need to update the active segment
if (segmentAtCurrentTime && activeSegment?.id !== segmentAtCurrentTime.id) {
logger.debug(`Entered segment ${segmentAtCurrentTime.id} during cutaway playback`);
setActiveSegment(segmentAtCurrentTime);
setSelectedSegmentId(segmentAtCurrentTime.id);
setShowEmptySpaceTooltip(false);
// Remove our boundary checker since we're now in a standard segment
videoRef.current.removeEventListener('timeupdate', checkCutawayBoundary);
// Reset continuation flags
setContinuePastBoundary(false);
sessionStorage.removeItem('continuingPastSegment');
return;
}
// If we've entered a segment, stop at its boundary
if (shouldStop && nextSegment) {
logger.debug(`CUTAWAY MANUAL BOUNDARY CHECK: Current position ${formatDetailedTime(currentPosition)} approaching segment at ${formatDetailedTime(nextSegment.startTime)} (distance: ${Math.abs(currentPosition - nextSegment.startTime).toFixed(3)}s) - STOPPING`);
videoRef.current.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 = nextSegment.startTime;
// Update UI immediately to match video position
onSeek(nextSegment.startTime);
// Also update tooltip time displays
setDisplayTime(nextSegment.startTime);
setClickedTime(nextSegment.startTime);
// Reset continuePastBoundary when stopping at a boundary
setContinuePastBoundary(false);
// Update tooltip to show the segment at the boundary
setSelectedSegmentId(nextSegment.id);
setShowEmptySpaceTooltip(false);
setActiveSegment(nextSegment);
// Force multiple adjustments to ensure exact precision
const verifyPosition = () => {
if (videoRef.current) {
// Always force the exact time in every verification
videoRef.current.currentTime = nextSegment.startTime;
// Make sure we update the UI to reflect the corrected position
onSeek(nextSegment.startTime);
// Update the displayTime and clickedTime state to match exact position
setDisplayTime(nextSegment.startTime);
setClickedTime(nextSegment.startTime);
logger.debug(`Position corrected to exact segment boundary: ${formatDetailedTime(videoRef.current.currentTime)} (target: ${formatDetailedTime(nextSegment.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);
// Remove our boundary checker
videoRef.current.removeEventListener('timeupdate', checkCutawayBoundary);
return;
}
};
// Start our manual boundary checker
videoRef.current.addEventListener('timeupdate', checkCutawayBoundary);
// Start playing with proper promise handling - use setTimeout to ensure
// that our activeSegment setting has had time to take effect
setTimeout(() => {
if (videoRef.current) {
// Now start playback
videoRef.current.play()
.then(() => {
setIsPlayingSegment(true);
logger.debug("CUTAWAY PLAYBACK STARTED:",
formatDetailedTime(startTime),
"to", formatDetailedTime(endTime),
previousSegment ?
`(after segment ${previousSegment.id}, offset +25ms from ${formatDetailedTime(previousSegment.endTime)})` :
"(from video start)",
nextSegment ? `(will stop at segment ${nextSegment.id})` : "(will play to end)"
);
})
.catch(err => {
console.error("Error playing cutaway:", err);
});
}
}, 50);
}
}}
>
<img src={playFromBeginningIcon} alt="Play from beginning" style={{width: '24px', height: '24px'}} />
</button>
{/* Play/Pause button for empty space */}
{/* <button
className={`tooltip-action-btn ${isPlaying ? 'pause' : 'play'}`}
data-tooltip={isPlaying ? "Pause playback" : "Play from here until next segment"}
onClick={(e) => {
e.stopPropagation();
if (videoRef.current) {
if (isPlaying) {
// If already playing, pause the video
videoRef.current.pause();
setIsPlayingSegment(false);
// Reset continuePastBoundary when stopping playback
setContinuePastBoundary(false);
logger.debug("Pause clicked in empty space - resetting continuePastBoundary flag");
} else {
// Enable continuePastBoundary flag when user explicitly clicks play
// This will allow playback to continue even if we're at segment boundary
setContinuePastBoundary(true);
logger.debug("Setting continuePastBoundary=true to allow playback through boundaries");
// Find the current time and determine cutaway boundaries
// For end, find the next segment after current position
// Make sure we look for any segment that starts after our current position,
// including the first segment if we're before it
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
const currentTime = videoRef.current.currentTime;
const nextSegment = sortedSegments.find(seg => seg.startTime > currentTime);
// Check if we're at a segment boundary that we previously stopped at
const isAtSegmentBoundary = nextSegment && Math.abs(currentTime - nextSegment.startTime) < 0.05;
if (isAtSegmentBoundary && nextSegment) {
// We're at the start of a segment - just continue into the segment rather than staying in cutaway
logger.debug(`At segment boundary: Moving into segment ${nextSegment.id}`);
// Update UI to show segment tooltip instead of empty space tooltip
setSelectedSegmentId(nextSegment.id);
setShowEmptySpaceTooltip(false);
// Set this segment as the active segment for boundary checking
setActiveSegment(nextSegment);
// Play from this segment directly
videoRef.current.play()
.then(() => {
setIsPlayingSegment(true);
logger.debug("Playing from segment start after boundary");
})
.catch(err => {
console.error("Error starting playback:", err);
});
return; // Exit early as we've handled this special case
}
// Define end boundary (either next segment start or video end)
const endTime = nextSegment ? nextSegment.startTime : duration;
// Special handling for when we're already at a segment boundary
// If we're at or extremely close to the segment boundary already,
// we need to nudge the position slightly back to allow playback
let adjustedCurrentTime = currentTime;
if (nextSegment && Math.abs(currentTime - nextSegment.startTime) < 0.05) {
logger.debug(`Already at boundary (${formatDetailedTime(currentTime)}), nudging position back slightly`);
adjustedCurrentTime = Math.max(0, currentTime - 0.1); // Move 100ms back
videoRef.current.currentTime = adjustedCurrentTime;
onSeek(adjustedCurrentTime);
logger.debug(`Position adjusted to ${formatDetailedTime(adjustedCurrentTime)}`);
}
// Create a virtual "segment" for the cutaway area
const cutawaySegment: Segment = {
id: -999, // Use a consistent negative ID for virtual segments
name: "Cutaway",
startTime: adjustedCurrentTime, // Use the potentially adjusted time
endTime: endTime,
thumbnail: ""
};
// IMPORTANT: First reset isPlayingSegment to false to ensure clean state
setIsPlayingSegment(false);
// Then set active segment for boundary checking
// We use setTimeout to ensure this happens in the next tick
// after the isPlayingSegment value is updated
setTimeout(() => {
setActiveSegment(cutawaySegment);
}, 0);
// Add a manual boundary check specifically for cutaway playback
// This ensures we detect when we reach the next segment's start
const checkCutawayBoundary = () => {
if (!videoRef.current) return;
// Check if we've entered a segment (i.e., reached a boundary)
const currentPosition = videoRef.current.currentTime;
const segments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
// Find the next segment we're approaching - use a wider detection range
// to catch the boundary earlier
const nextSegment = segments.find(seg => seg.startTime > currentPosition - 0.3);
// Also check if we've entered a different segment - we need to detect this too
const segmentAtCurrentTime = segments.find(
seg => currentPosition >= seg.startTime && currentPosition <= seg.endTime
);
// If we've moved directly into a segment during playback, we need to update the active segment
if (segmentAtCurrentTime && activeSegment?.id !== segmentAtCurrentTime.id) {
logger.debug(`Entered segment ${segmentAtCurrentTime.id} during cutaway playback`);
setActiveSegment(segmentAtCurrentTime);
setSelectedSegmentId(segmentAtCurrentTime.id);
setShowEmptySpaceTooltip(false);
// Remove our boundary checker since we're now in a standard segment
videoRef.current.removeEventListener('timeupdate', checkCutawayBoundary);
// Reset continuation flags
setContinuePastBoundary(false);
sessionStorage.removeItem('continuingPastSegment');
return;
}
// We need to detect boundaries much earlier to allow for time to react
// This is a key fix - we need to detect the boundary BEFORE we reach it
// But don't stop if we're in continuePastBoundary mode
const shouldStop = nextSegment &&
(currentPosition >= nextSegment.startTime - 0.25) &&
(currentPosition <= nextSegment.startTime + 0.1) &&
!continuePastBoundary;
// Add logging to show boundary check decisions
if (nextSegment && (currentPosition >= nextSegment.startTime - 0.25) &&
(currentPosition <= nextSegment.startTime + 0.1)) {
logger.debug(`Approaching boundary at ${formatDetailedTime(nextSegment.startTime)}, continuePastBoundary=${continuePastBoundary}, willStop=${shouldStop}`);
}
// If we've entered a segment, stop at its boundary
if (shouldStop && nextSegment) {
logger.debug(`CUTAWAY MANUAL BOUNDARY CHECK: Current position ${formatDetailedTime(currentPosition)} approaching segment at ${formatDetailedTime(nextSegment.startTime)} (distance: ${Math.abs(currentPosition - nextSegment.startTime).toFixed(3)}s) - STOPPING`);
videoRef.current.pause();
// Force exact time position with high precision
setTimeout(() => {
if (videoRef.current) {
// First seek directly to exact start time, no offset
videoRef.current.currentTime = nextSegment.startTime;
// Update UI immediately to match video position
onSeek(nextSegment.startTime);
// Also update tooltip time displays
setDisplayTime(nextSegment.startTime);
setClickedTime(nextSegment.startTime);
// Reset continuePastBoundary when stopping at a boundary
setContinuePastBoundary(false);
// Update tooltip to show the segment at the boundary
setSelectedSegmentId(nextSegment.id);
setShowEmptySpaceTooltip(false);
setActiveSegment(nextSegment);
// Force multiple adjustments to ensure exact precision
const verifyPosition = () => {
if (videoRef.current) {
// Always force the exact time in every verification
videoRef.current.currentTime = nextSegment.startTime;
// Make sure we update the UI to reflect the corrected position
onSeek(nextSegment.startTime);
// Update the displayTime and clickedTime state to match exact position
setDisplayTime(nextSegment.startTime);
setClickedTime(nextSegment.startTime);
logger.debug(`Position corrected to exact segment boundary: ${formatDetailedTime(videoRef.current.currentTime)} (target: ${formatDetailedTime(nextSegment.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);
// Remove our boundary checker
videoRef.current.removeEventListener('timeupdate', checkCutawayBoundary);
return;
}
};
// Start our manual boundary checker
videoRef.current.addEventListener('timeupdate', checkCutawayBoundary);
// Start playing from current position with boundary restrictions
// Use a timeout to ensure active segment is set before playback starts
setTimeout(() => {
if (videoRef.current) {
videoRef.current.play()
.then(() => {
setIsPlayingSegment(true);
logger.debug("Play clicked in empty space - position:",
formatDetailedTime(currentTime),
"will stop at:", formatDetailedTime(endTime),
nextSegment ? `(start of segment ${nextSegment.id})` : "(end of video)"
);
})
.catch(err => {
console.error("Error starting playback:", err);
});
}
}, 50);
}
}
}}
>
{isPlaying ? (
<img src={pauseIcon} alt="Pause" style={{width: '24px', height: '24px'}} />
) : (
<img src={playIcon} alt="Play" style={{width: '24px', height: '24px'}} />
)}
</button> */}
{/* Play/Pause button for empty space - Same as main play/pause button */}
<button
className={`tooltip-action-btn ${isPlaying ? 'pause' : 'play'}`}
data-tooltip={isPlaying ? "Pause playback" : "Play from here until next segment"}
onClick={(e) => {
e.stopPropagation();
if (isPlaying) {
// If playing, just pause
if (videoRef.current) {
videoRef.current.pause();
setIsPlayingSegment(false);
setContinuePastBoundary(false);
}
} else {
onPlayPause();
}
}}
>
{isPlaying ? (
<img src={pauseIcon} alt="Pause" style={{width: '24px', height: '24px'}} />
) : (
<img src={playIcon} alt="Play" style={{width: '24px', height: '24px'}} />
)}
</button>
{/* Segment end adjustment button (always shown) */}
<button
className="tooltip-action-btn segment-end"
data-tooltip="Adjust end of previous segment or create segment from start"
onClick={(e) => {
e.stopPropagation();
// Find the previous segment (one that ends before the current time)
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
const prevSegment = sortedSegments.filter(seg => seg.endTime <= clickedTime)
.sort((a, b) => b.endTime - a.endTime)[0]; // Get the closest one before
if (prevSegment) {
// Regular case: adjust end of previous segment
const updatedSegments = clipSegments.map(seg => {
if (seg.id === prevSegment.id) {
return {
...seg,
endTime: clickedTime
};
}
return seg;
});
// Create and dispatch the update event
const updateEvent = new CustomEvent('update-segments', {
detail: {
segments: updatedSegments,
recordHistory: true,
action: 'adjust_previous_end_time'
}
});
document.dispatchEvent(updateEvent);
logger.debug("Adjusted end of previous segment to:", formatDetailedTime(clickedTime));
// Show the previous segment's tooltip
setSelectedSegmentId(prevSegment.id);
setShowEmptySpaceTooltip(false);
} else if (clipSegments.length > 0) {
// No previous segment at cursor position, but segments exist elsewhere
// First, check if we're in a gap between segments - if so, create a new segment for the gap
const sortedByStart = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
let inGap = false;
let gapStart = 0;
// Check if we're in a gap between segments
for (let i = 0; i < sortedByStart.length - 1; i++) {
const currentSegEnd = sortedByStart[i].endTime;
const nextSegStart = sortedByStart[i + 1].startTime;
if (clickedTime > currentSegEnd && clickedTime < nextSegStart) {
inGap = true;
gapStart = currentSegEnd;
break;
}
}
if (inGap) {
// We're in a gap, create a new segment from gap start to clicked time
const newSegment: Segment = {
id: Date.now(),
name: 'segment',
startTime: gapStart,
endTime: clickedTime,
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
};
// Add the new segment to existing segments
const updatedSegments = [...clipSegments, newSegment];
// Create and dispatch the update event
const updateEvent = new CustomEvent('update-segments', {
detail: {
segments: updatedSegments,
recordHistory: true,
action: 'create_segment_in_gap'
}
});
document.dispatchEvent(updateEvent);
logger.debug("Created new segment in gap from", formatDetailedTime(gapStart), "to", formatDetailedTime(clickedTime));
// Show the new segment's tooltip
setSelectedSegmentId(newSegment.id);
setShowEmptySpaceTooltip(false);
}
// Check if we're before all segments and should create a segment from start
else if (clickedTime < sortedByStart[0].startTime) {
// Create a new segment from start of video to clicked time
const newSegment: Segment = {
id: Date.now(),
name: 'segment',
startTime: 0,
endTime: clickedTime,
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
};
// Add the new segment to existing segments
const updatedSegments = [...clipSegments, newSegment];
// Create and dispatch the update event
const updateEvent = new CustomEvent('update-segments', {
detail: {
segments: updatedSegments,
recordHistory: true,
action: 'create_segment_from_start'
}
});
document.dispatchEvent(updateEvent);
logger.debug("Created new segment from start to:", formatDetailedTime(clickedTime));
// Show the new segment's tooltip
setSelectedSegmentId(newSegment.id);
setShowEmptySpaceTooltip(false);
}
else {
// Not in a gap, check if we can extend the last segment to end of video
const lastSegment = [...clipSegments].sort((a, b) => b.endTime - a.endTime)[0];
if (lastSegment && lastSegment.endTime < duration) {
// Extend the last segment to end of video
const updatedSegments = clipSegments.map(seg => {
if (seg.id === lastSegment.id) {
return {
...seg,
endTime: duration
};
}
return seg;
});
// Create and dispatch the update event
const updateEvent = new CustomEvent('update-segments', {
detail: {
segments: updatedSegments,
recordHistory: true,
action: 'extend_last_segment'
}
});
document.dispatchEvent(updateEvent);
logger.debug("Extended last segment to end of video");
// Show the last segment's tooltip
setSelectedSegmentId(lastSegment.id);
setShowEmptySpaceTooltip(false);
}
}
} else if (clickedTime > 0) {
// No segments exist; create a new segment from start to clicked time
const newSegment: Segment = {
id: Date.now(),
name: 'segment',
startTime: 0,
endTime: clickedTime,
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
};
// Create and dispatch the update event
const updateEvent = new CustomEvent('update-segments', {
detail: {
segments: [newSegment],
recordHistory: true,
action: 'create_segment_from_start'
}
});
document.dispatchEvent(updateEvent);
logger.debug("Created new segment from start to:", formatDetailedTime(clickedTime));
// Show the new segment's tooltip
setSelectedSegmentId(newSegment.id);
setShowEmptySpaceTooltip(false);
}
}}
>
<img src={segmentNewEndIcon} alt="Set end point" style={{width: '24px', height: '24px'}} />
</button>
{/* Segment start adjustment button (always shown) */}
<button
className="tooltip-action-btn segment-start"
data-tooltip="Adjust start of next segment or create segment to end"
onClick={(e) => {
e.stopPropagation();
// Find the next segment (one that starts after the current time)
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
const nextSegment = sortedSegments.filter(seg => seg.startTime >= clickedTime)
.sort((a, b) => a.startTime - b.startTime)[0]; // Get the closest one after
if (nextSegment) {
// Regular case: adjust start of next segment
const updatedSegments = clipSegments.map(seg => {
if (seg.id === nextSegment.id) {
return {
...seg,
startTime: clickedTime
};
}
return seg;
});
// Create and dispatch the update event
const updateEvent = new CustomEvent('update-segments', {
detail: {
segments: updatedSegments,
recordHistory: true,
action: 'adjust_next_start_time'
}
});
document.dispatchEvent(updateEvent);
logger.debug("Adjusted start of next segment to:", formatDetailedTime(clickedTime));
// Show the next segment's tooltip
setSelectedSegmentId(nextSegment.id);
setShowEmptySpaceTooltip(false);
} else if (clipSegments.length > 0) {
// No next segment at cursor position, but segments exist elsewhere
// First, check if we're in a gap between segments - if so, create a new segment for the gap
const sortedByStart = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
let inGap = false;
let gapEnd = 0;
// Check if we're in a gap between segments
for (let i = 0; i < sortedByStart.length - 1; i++) {
const currentSegEnd = sortedByStart[i].endTime;
const nextSegStart = sortedByStart[i + 1].startTime;
if (clickedTime > currentSegEnd && clickedTime < nextSegStart) {
inGap = true;
gapEnd = nextSegStart;
break;
}
}
if (inGap) {
// We're in a gap, create a new segment from clicked time to gap end
const newSegment: Segment = {
id: Date.now(),
name: 'segment',
startTime: clickedTime,
endTime: gapEnd,
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
};
// Add the new segment to existing segments
const updatedSegments = [...clipSegments, newSegment];
// Create and dispatch the update event
const updateEvent = new CustomEvent('update-segments', {
detail: {
segments: updatedSegments,
recordHistory: true,
action: 'create_segment_in_gap'
}
});
document.dispatchEvent(updateEvent);
logger.debug("Created new segment in gap from", formatDetailedTime(clickedTime), "to", formatDetailedTime(gapEnd));
// Show the new segment's tooltip
setSelectedSegmentId(newSegment.id);
setShowEmptySpaceTooltip(false);
} else {
// Check if we're at the start of the video with segments ahead
if (clickedTime < sortedByStart[0].startTime) {
// Create a new segment from clicked time to first segment start
const newSegment: Segment = {
id: Date.now(),
name: 'segment',
startTime: clickedTime,
endTime: sortedByStart[0].startTime,
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
};
// Add the new segment to existing segments
const updatedSegments = [...clipSegments, newSegment];
// Create and dispatch the update event
const updateEvent = new CustomEvent('update-segments', {
detail: {
segments: updatedSegments,
recordHistory: true,
action: 'create_segment_before_first'
}
});
document.dispatchEvent(updateEvent);
logger.debug("Created new segment from", formatDetailedTime(clickedTime), "to first segment");
// Show the new segment's tooltip
setSelectedSegmentId(newSegment.id);
setShowEmptySpaceTooltip(false);
}
// Check if we're after all segments and should create a segment to the end
else if (clickedTime > sortedByStart[sortedByStart.length - 1].endTime) {
// Create a new segment from clicked time to end of video
const newSegment: Segment = {
id: Date.now(),
name: 'segment',
startTime: clickedTime,
endTime: duration,
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
};
// Add the new segment to existing segments
const updatedSegments = [...clipSegments, newSegment];
// Create and dispatch the update event
const updateEvent = new CustomEvent('update-segments', {
detail: {
segments: updatedSegments,
recordHistory: true,
action: 'create_segment_to_end'
}
});
document.dispatchEvent(updateEvent);
logger.debug("Created new segment from", formatDetailedTime(clickedTime), "to end");
// Show the new segment's tooltip
setSelectedSegmentId(newSegment.id);
setShowEmptySpaceTooltip(false);
}
else {
// Not in a gap, check if we can extend the first segment to start of video
const firstSegment = sortedByStart[0];
if (firstSegment && firstSegment.startTime > 0) {
// Extend the first segment to start of video
const updatedSegments = clipSegments.map(seg => {
if (seg.id === firstSegment.id) {
return {
...seg,
startTime: 0
};
}
return seg;
});
// Create and dispatch the update event
const updateEvent = new CustomEvent('update-segments', {
detail: {
segments: updatedSegments,
recordHistory: true,
action: 'extend_first_segment'
}
});
document.dispatchEvent(updateEvent);
logger.debug("Extended first segment to start of video");
// Show the first segment's tooltip
setSelectedSegmentId(firstSegment.id);
setShowEmptySpaceTooltip(false);
}
}
}
} else if (clickedTime < duration) {
// No segments exist; create a new segment from clicked time to end
const newSegment: Segment = {
id: Date.now(),
name: 'segment',
startTime: clickedTime,
endTime: duration,
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
};
// Create and dispatch the update event
const updateEvent = new CustomEvent('update-segments', {
detail: {
segments: [newSegment],
recordHistory: true,
action: 'create_segment_to_end'
}
});
document.dispatchEvent(updateEvent);
logger.debug("Created new segment from", formatDetailedTime(clickedTime), "to end");
// Show the new segment's tooltip
setSelectedSegmentId(newSegment.id);
setShowEmptySpaceTooltip(false);
}
}}
>
<img src={segmentNewStartIcon} alt="Set start point" style={{width: '24px', height: '24px'}} />
</button>
</div>
</div>
)}
</div>
</div>
{/* Precise Time Navigation & Zoom Controls */}
<div className="timeline-controls">
{/* Precise Time Input */}
<div className="time-navigation">
<div className="time-nav-label">Go to Time:</div>
<input
type="text"
className="time-input"
placeholder="00:00:00.000"
data-tooltip="Enter time in format: hh:mm:ss.ms"
onKeyDown={(e) => {
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 */}
<div className="time-button-group">
{(() => {
// 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 (
<>
<button
className="time-button"
onClick={() => {
// Move back 10ms
onSeek(currentTime - 0.01);
// Show appropriate tooltip
setTimeout(showTooltipAtCurrentTime, 10); // Short delay to ensure time is updated
}}
data-tooltip="Move back 10ms"
>
-10ms
</button>
<button
className="time-button"
onClick={() => {
// Move back 1ms
onSeek(currentTime - 0.001);
// Show appropriate tooltip
setTimeout(showTooltipAtCurrentTime, 10);
}}
data-tooltip="Move back 1ms"
>
-1ms
</button>
<button
className="time-button"
onClick={() => {
// Move forward 1ms
onSeek(currentTime + 0.001);
// Show appropriate tooltip
setTimeout(showTooltipAtCurrentTime, 10);
}}
data-tooltip="Move forward 1ms"
>
+1ms
</button>
<button
className="time-button"
onClick={() => {
// Move forward 10ms
onSeek(currentTime + 0.01);
// Show appropriate tooltip
setTimeout(showTooltipAtCurrentTime, 10);
}}
data-tooltip="Move forward 10ms"
>
+10ms
</button>
</>
);
})()}
</div>
</div>
{/* Zoom Dropdown Control and Save Buttons */}
<div className="controls-right">
<div className="zoom-dropdown-container">
<button
className="zoom-button"
data-tooltip="Select zoom level"
onClick={() => setIsZoomDropdownOpen(!isZoomDropdownOpen)}
>
Zoom {zoomLevel}x
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
{isZoomDropdownOpen && (
<div className="zoom-dropdown" style={{ position: 'absolute', top: '100%', left: 0, zIndex: 1000 }}>
{[1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096].map(level => (
<div
key={level}
className={`zoom-option ${zoomLevel === level ? 'selected' : ''}`}
onClick={() => {
onZoomChange(level);
setIsZoomDropdownOpen(false);
}}
>
{zoomLevel === level && (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
)}
Zoom {level}x
</div>
))}
</div>
)}
</div>
{/* Save Buttons Row */}
<div className="save-buttons-row">
{onSave && (
<button
onClick={() => setShowSaveModal(true)}
className="save-button"
data-tooltip="Save changes"
>
Save
</button>
)}
{onSaveACopy && (
<button
onClick={() => setShowSaveAsModal(true)}
className="save-copy-button"
data-tooltip="Save as a new copy"
>
Save as Copy
</button>
)}
{onSaveSegments && (
<button
onClick={() => setShowSaveSegmentsModal(true)}
className="save-segments-button"
data-tooltip="Save segments as separate files"
>
Save Segments
</button>
)}
</div>
{/* Save Confirmation Modal */}
<Modal
isOpen={showSaveModal}
onClose={() => setShowSaveModal(false)}
title="Save Changes"
actions={
<>
<button
className="modal-button modal-button-secondary"
onClick={() => setShowSaveModal(false)}
>
Cancel
</button>
<button
className="modal-button modal-button-primary"
onClick={() => {
// Reset unsaved changes flag before saving
if (onSave) onSave();
handleSaveConfirm();
}}
>
Confirm Save
</button>
</>
}
>
<p className="modal-message">
You're about to save these changes and replace the original video. This action cannot be undone.
</p>
<p className="modal-message">
The original video will be replaced with this trimmed version.
</p>
</Modal>
{/* Save As Copy Modal */}
<Modal
isOpen={showSaveAsModal}
onClose={() => setShowSaveAsModal(false)}
title="Save As New Copy"
actions={
<>
<button
className="modal-button modal-button-secondary"
onClick={() => setShowSaveAsModal(false)}
>
Cancel
</button>
<button
className="modal-button modal-button-primary"
onClick={() => {
// Reset unsaved changes flag before saving
if (onSaveACopy) onSaveACopy();
handleSaveAsCopyConfirm();
}}
>
Confirm Save As Copy
</button>
</>
}
>
<p className="modal-message">
You're about to save these changes as a new copy. Your original video will remain unchanged.
</p>
<p className="modal-message">
A new copy of the video will be created with your trimmed segments.
</p>
</Modal>
{/* Processing Modal */}
<Modal
isOpen={showProcessingModal}
onClose={() => {}}
title="Processing Video"
>
<div className="modal-spinner">
<div className="spinner"></div>
</div>
<p className="modal-message text-center">
Please wait while your video is being processed...
</p>
</Modal>
{/* Save Segments Modal */}
<Modal
isOpen={showSaveSegmentsModal}
onClose={() => setShowSaveSegmentsModal(false)}
title="Save Segments"
actions={
<>
<button
className="modal-button modal-button-secondary"
onClick={() => setShowSaveSegmentsModal(false)}
>
Cancel
</button>
<button
className="modal-button modal-button-primary"
onClick={() => {
// Reset unsaved changes flag before saving
if (onSaveSegments) onSaveSegments();
handleSaveSegmentsConfirm();
}}
>
Save Segments
</button>
</>
}
>
<p className="modal-message">
You're about to save each segment as a separate video file.
There are {clipSegments.length} segments to be saved.
</p>
<p className="modal-message">
Each segment will be saved with its name as the filename.
</p>
</Modal>
{/* Success Modal */}
<Modal
isOpen={showSuccessModal}
onClose={() => setShowSuccessModal(false)}
title="Video Edited Successfully"
>
<div className="modal-success-content">
{/* <p className="modal-message text-center">
{successMessage || "Processing completed successfully!"}
</p> */}
<p className="modal-message text-center redirect-message">
{saveType === "segments"
? "You will be redirected to your "
: "You will be redirected to your "}
<a href={redirectUrl} className="media-page-link" style={mediaPageLinkStyles}>media page</a>
{" in "}
<span className="countdown">10</span> seconds. {' '}
{saveType === "segments"
? "The new video(s) will soon be there."
: "Changes to the video might take a few minutes to be applied."}
</p>
</div>
</Modal>
{/* Error Modal */}
<Modal
isOpen={showErrorModal}
onClose={() => setShowErrorModal(false)}
title="Video Processing Error"
>
<div className="modal-error-content">
<div className="modal-error-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#F44336" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
</div>
<p className="modal-message text-center error-message">
{errorMessage}
</p>
</div>
<div className="modal-choices">
<button
onClick={() => setShowErrorModal(false)}
className="modal-choice-button centered-choice"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
Close
</button>
</div>
</Modal>
{/* Dropdown was moved inside the container element */}
</div>
</div>
{/* Mobile Uninitialized Overlay - Show only when on mobile and video hasn't been played yet */}
{isIOSUninitialized && (
<div className="mobile-timeline-overlay">
<div className="mobile-timeline-message">
<p>Please play the video first to enable timeline controls</p>
<div className="mobile-play-icon"></div>
</div>
</div>
)}
</div>
);
};
export default TimelineControls;