Files
mediacms/frontend-tools/chapters-editor/client/src/components/TimelineControls.tsx
Yiannis Christodoulou 5eb6fafb8c fix: Show default chapter names in textarea instead of placeholder text (#1428)
* Refactor chapter filtering and auto-save logic

Simplified chapter filtering to only exclude empty titles, allowing default chapter names. Updated auto-save logic to skip saving when there are no chapters or mediaId. Removed unused helper function and improved debug logging.

* Show default chapter title in editor and set initial title

The chapter title is now always displayed in the textarea, including default names like 'Chapter 1'. Also, the initial segment is created with 'Chapter 1' as its title instead of an empty string for better clarity.

* build assets
2025-11-12 14:04:07 +02:00

4085 lines
210 KiB
TypeScript

import { useRef, useEffect, useState, useCallback } from 'react';
import { formatTime, formatDetailedTime } from '../lib/timeUtils';
import { generateSolidColor } from '../lib/videoUtils';
import { Segment } from './ClipSegments';
import Modal from './Modal';
import { autoSaveVideo } from '../services/videoApi';
import logger from '../lib/logger';
import '../styles/TimelineControls.css';
import '../styles/TwoRowTooltip.css';
import playIcon from '../assets/play-icon.svg';
import pauseIcon from '../assets/pause-icon.svg';
import playFromBeginningIcon from '../assets/play-from-beginning-icon.svg';
import segmentEndIcon from '../assets/segment-end-new.svg';
import segmentStartIcon from '../assets/segment-start-new.svg';
import segmentNewStartIcon from '../assets/segment-start-new-cutaway.svg';
import segmentNewEndIcon from '../assets/segment-end-new-cutaway.svg';
// Add styles for the media page link
const mediaPageLinkStyles = {
color: '#007bff',
textDecoration: 'none',
fontWeight: 'bold',
'&:hover': {
textDecoration: 'underline',
color: '#0056b3',
},
} as const;
interface TimelineControlsProps {
currentTime: number;
duration: number;
thumbnails: string[];
trimStart: number;
trimEnd: number;
splitPoints: number[];
zoomLevel: number;
clipSegments: Segment[];
selectedSegmentId?: number | null;
onSelectedSegmentChange?: (segmentId: number | null) => void;
onSegmentUpdate?: (segmentId: number, updates: Partial<Segment>) => void;
onChapterSave?: (chapters: { chapterTitle: string; from: string; to: string }[]) => void;
onTrimStartChange: (time: number) => void;
onTrimEndChange: (time: number) => void;
onZoomChange: (level: number) => void;
onSeek: (time: number) => void;
videoRef: React.RefObject<HTMLVideoElement>;
hasUnsavedChanges?: boolean;
isIOSUninitialized?: boolean;
isPlaying: boolean;
setIsPlaying: (playing: boolean) => void;
onPlayPause: () => void;
isPlayingSegments?: boolean;
}
// Function to calculate and constrain tooltip position to keep it on screen
// Uses smooth transitions instead of hard breakpoints to eliminate jumping
const constrainTooltipPosition = (positionPercent: number) => {
// Smooth transition zones instead of hard breakpoints
const leftTransitionStart = 0;
const leftTransitionEnd = 15;
const rightTransitionStart = 75;
const rightTransitionEnd = 100;
let leftValue: string;
let transform: string;
if (positionPercent <= leftTransitionEnd) {
// Left side: smooth transition from center to left-aligned
if (positionPercent <= leftTransitionStart) {
// Fully left-aligned
leftValue = '0%';
transform = 'none';
} else {
// Smooth transition zone
const transitionProgress =
(positionPercent - leftTransitionStart) / (leftTransitionEnd - leftTransitionStart);
const translateAmount = -50 * transitionProgress; // Gradually reduce from 0% to -50%
leftValue = `${positionPercent}%`;
transform = `translateX(${translateAmount}%)`;
}
} else if (positionPercent >= rightTransitionStart) {
// Right side: smooth transition from center to right-aligned
if (positionPercent >= rightTransitionEnd) {
// Fully right-aligned
leftValue = '100%';
transform = 'translateX(-100%)';
} else {
// Smooth transition zone
const transitionProgress =
(positionPercent - rightTransitionStart) / (rightTransitionEnd - rightTransitionStart);
const translateAmount = -50 - 50 * transitionProgress; // Gradually change from -50% to -100%
leftValue = `${positionPercent}%`;
transform = `translateX(${translateAmount}%)`;
}
} else {
// Center zone: normal centered positioning
leftValue = `${positionPercent}%`;
transform = 'translateX(-50%)';
}
return { left: leftValue, transform };
};
const TimelineControls = ({
currentTime,
duration,
trimStart,
trimEnd,
splitPoints,
zoomLevel,
clipSegments,
selectedSegmentId: externalSelectedSegmentId,
onSelectedSegmentChange,
onSegmentUpdate,
onChapterSave,
onTrimStartChange,
onTrimEndChange,
onZoomChange,
onSeek,
videoRef,
hasUnsavedChanges = false,
isIOSUninitialized = false,
isPlaying,
setIsPlaying,
onPlayPause, // Add this prop
isPlayingSegments = false,
}: TimelineControlsProps) => {
// Helper function to generate proper chapter name based on chronological position
const generateChapterName = (newSegmentStartTime: number, existingSegments: Segment[]): string => {
// Create a temporary array with all segments including the new one
const allSegments = [...existingSegments, { startTime: newSegmentStartTime } as Segment];
// Sort by start time to find chronological position
const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime);
// Find the index of our new segment
const chapterIndex = sortedSegments.findIndex(seg => seg.startTime === newSegmentStartTime);
return `Chapter ${chapterIndex + 1}`;
};
const timelineRef = useRef<HTMLDivElement>(null);
const leftHandleRef = useRef<HTMLDivElement>(null);
const rightHandleRef = useRef<HTMLDivElement>(null);
// Use external selectedSegmentId if provided, otherwise use internal state
const [internalSelectedSegmentId, setInternalSelectedSegmentId] = useState<number | null>(null);
const selectedSegmentId =
externalSelectedSegmentId !== undefined ? externalSelectedSegmentId : internalSelectedSegmentId;
const setSelectedSegmentId = (segmentId: number | null) => {
if (onSelectedSegmentChange) {
onSelectedSegmentChange(segmentId);
} else {
setInternalSelectedSegmentId(segmentId);
}
};
const [showEmptySpaceTooltip, setShowEmptySpaceTooltip] = useState(false);
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
const [clickedTime, setClickedTime] = useState<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);
// Chapter editor state
const [editingChapterTitle, setEditingChapterTitle] = useState<string>('');
const [chapterHasUnsavedChanges, setChapterHasUnsavedChanges] = useState(false);
// Sort segments by startTime for chapter editor
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
const selectedSegment = sortedSegments.find((seg) => seg.id === selectedSegmentId);
// Auto-save related state
const [lastAutoSaveTime, setLastAutoSaveTime] = useState<string>('');
const [isAutoSaving, setIsAutoSaving] = useState(false);
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const clipSegmentsRef = useRef(clipSegments);
// Keep clipSegmentsRef updated
useEffect(() => {
clipSegmentsRef.current = clipSegments;
}, [clipSegments]);
// Auto-save function
const performAutoSave = useCallback(async () => {
try {
setIsAutoSaving(true);
// Format segments data for API request - use ref to get latest segments and sort by start time
const chapters = clipSegmentsRef.current
.sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically
.map((chapter) => ({
startTime: formatDetailedTime(chapter.startTime),
endTime: formatDetailedTime(chapter.endTime),
chapterTitle: chapter.chapterTitle,
}));
logger.debug('chapters', chapters);
const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null;
// For testing, use '1234' if no mediaId is available
const finalMediaId = mediaId || '1234';
logger.debug('mediaId', finalMediaId);
if (!finalMediaId || chapters.length === 0) {
logger.debug('No mediaId or segments, skipping auto-save');
setIsAutoSaving(false);
return;
}
logger.debug('Auto-saving segments:', { mediaId: finalMediaId, chapters });
const response = await autoSaveVideo(finalMediaId, { chapters });
if (response.success === true) {
logger.debug('Auto-save successful');
// Format the timestamp for display
const date = new Date(response.updated_at || new Date().toISOString());
const formattedTime = date
.toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
.replace(',', '');
setLastAutoSaveTime(formattedTime);
logger.debug('Auto-save successful:', formattedTime);
} else {
logger.error('Auto-save failed: (TimelineControls.tsx)');
}
} catch (error) {
logger.error('Auto-save error: (TimelineControls.tsx)', error);
} finally {
setIsAutoSaving(false);
}
}, []);
// Schedule auto-save with debounce
const scheduleAutoSave = useCallback(() => {
// Clear any existing timer
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
logger.debug('Cleared existing auto-save timer');
}
logger.debug('Scheduling new auto-save in 1 second...');
// Schedule new auto-save after 1 second of inactivity
const timerId = setTimeout(() => {
logger.debug('Auto-save timer fired! Calling performAutoSave...');
performAutoSave();
}, 1000);
autoSaveTimerRef.current = timerId;
logger.debug('Timer ID set:', timerId);
}, [performAutoSave]);
// Update editing title when selected segment changes
useEffect(() => {
if (selectedSegment) {
// Always show the chapter title in the textarea, whether it's default or custom
setEditingChapterTitle(selectedSegment.chapterTitle || '');
} else {
setEditingChapterTitle('');
}
}, [selectedSegmentId, selectedSegment]);
// Handle chapter title change
const handleChapterTitleChange = (value: string) => {
setEditingChapterTitle(value);
setChapterHasUnsavedChanges(true);
// Update the segment immediately
if (selectedSegmentId && onSegmentUpdate) {
onSegmentUpdate(selectedSegmentId, { chapterTitle: value });
}
};
// Handle save chapters
/* const handleSaveChapters = () => {
if (!onChapterSave) return;
// Convert segments to chapter format
const chapters = sortedSegments.map((segment, index) => ({
name: segment.chapterTitle || `Chapter ${index + 1}`,
from: formatDetailedTime(segment.startTime),
to: formatDetailedTime(segment.endTime),
}));
onChapterSave(chapters);
setChapterHasUnsavedChanges(false);
}; */
// Helper function for time adjustment buttons to maintain playback state
/* const handleTimeAdjustment = (offsetSeconds: number) => (e: React.MouseEvent) => {
e.stopPropagation();
// Calculate new time based on offset (positive or negative)
const newTime =
offsetSeconds < 0
? Math.max(0, clickedTime + offsetSeconds) // For negative offsets (going back)
: Math.min(duration, clickedTime + offsetSeconds); // For positive offsets (going forward)
// Save the current playing state before seeking
const wasPlaying = isPlayingSegment;
// Seek to the new time
onSeek(newTime);
// Update both clicked time and display time
setClickedTime(newTime);
setDisplayTime(newTime);
// Resume playback if it was playing before
if (wasPlaying && videoRef.current) {
videoRef.current.play();
setIsPlayingSegment(true);
}
}; */
// Enhanced helper for continuous time adjustment when button is held down
const handleContinuousTimeAdjustment = (offsetSeconds: number) => {
// Fixed adjustment amount - exactly 50ms each time
const adjustmentValue = offsetSeconds;
// Hold timer for continuous adjustment
let holdTimer: NodeJS.Timeout | null = null;
let continuousTimer: NodeJS.Timeout | null = null;
// Store the last time value to correctly calculate the next increment
let lastTimeValue = clickedTime;
// Function to perform time adjustment
const adjustTime = () => {
// Calculate new time based on fixed offset (positive or negative)
const newTime =
adjustmentValue < 0
? Math.max(0, lastTimeValue + adjustmentValue) // For negative offsets (going back)
: Math.min(duration, lastTimeValue + adjustmentValue); // For positive offsets (going forward)
// Update our last time value for next adjustment
lastTimeValue = newTime;
// Save the current playing state before seeking
const wasPlaying = isPlayingSegment;
// Seek to the new time
onSeek(newTime);
// Update both clicked time and display time
setClickedTime(newTime);
setDisplayTime(newTime);
// Update tooltip position
if (timelineRef.current) {
const rect = timelineRef.current.getBoundingClientRect();
const positionPercent = (newTime / duration) * 100;
const xPos = rect.left + rect.width * (positionPercent / 100);
setTooltipPosition({
x: xPos,
y: rect.top - 10,
});
// Find if we're in a segment at the new time
const segmentAtTime = clipSegments.find((seg) => newTime >= seg.startTime && newTime <= seg.endTime);
if (segmentAtTime) {
// Show segment tooltip
setSelectedSegmentId(segmentAtTime.id);
setShowEmptySpaceTooltip(false);
} else {
// Show cutaway tooltip
setSelectedSegmentId(null);
const availableSpace = calculateAvailableSpace(newTime);
setAvailableSegmentDuration(availableSpace);
setShowEmptySpaceTooltip(true);
}
}
// Resume playback if it was playing before
if (wasPlaying && videoRef.current) {
videoRef.current.play();
setIsPlayingSegment(true);
}
};
// Return mouse event handlers with touch support
return {
onMouseDown: (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
// Update the initial last time value
lastTimeValue = clickedTime;
// Perform initial adjustment
adjustTime();
// Start continuous adjustment after 1.5s hold
holdTimer = setTimeout(() => {
// After 1.5s delay, start adjusting at a slower pace (every 200ms)
continuousTimer = setInterval(adjustTime, 200);
}, 750);
// Add mouse up and leave handlers to document to ensure we catch the release
const clearTimers = () => {
if (holdTimer) {
clearTimeout(holdTimer);
holdTimer = null;
}
if (continuousTimer) {
clearInterval(continuousTimer);
continuousTimer = null;
}
document.removeEventListener('mouseup', clearTimers);
document.removeEventListener('mouseleave', clearTimers);
};
document.addEventListener('mouseup', clearTimers);
document.addEventListener('mouseleave', clearTimers);
},
onTouchStart: (e: React.TouchEvent) => {
e.stopPropagation();
e.preventDefault();
21;
// Update the initial last time value
lastTimeValue = clickedTime;
// Perform initial adjustment
adjustTime();
// Start continuous adjustment after 1.5s hold
holdTimer = setTimeout(() => {
// After 1.5s delay, start adjusting at a slower pace (every 200ms)
continuousTimer = setInterval(adjustTime, 200);
}, 750);
// Add touch end handler to ensure we catch the release
const clearTimers = () => {
if (holdTimer) {
clearTimeout(holdTimer);
holdTimer = null;
}
if (continuousTimer) {
clearInterval(continuousTimer);
continuousTimer = null;
}
document.removeEventListener('touchend', clearTimers);
document.removeEventListener('touchcancel', clearTimers);
};
document.addEventListener('touchend', clearTimers);
document.addEventListener('touchcancel', clearTimers);
},
onClick: (e: React.MouseEvent) => {
// This prevents the click event from firing twice
e.stopPropagation();
},
};
};
// Modal states
const [showSaveChaptersModal, setShowSaveChaptersModal] = useState(false);
const [showProcessingModal, setShowProcessingModal] = useState(false);
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [showErrorModal, setShowErrorModal] = useState(false);
const [successMessage, setSuccessMessage] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [redirectUrl, setRedirectUrl] = useState('');
const [saveType, setSaveType] = useState<'chapters'>('chapters');
// Calculate positions as percentages
const currentTimePercent = duration > 0 ? (currentTime / duration) * 100 : 0;
const trimStartPercent = duration > 0 ? (trimStart / duration) * 100 : 0;
const trimEndPercent = duration > 0 ? (trimEnd / duration) * 100 : 0;
// No need for an extra effect here as we handle displayTime updates in the segment playback effect
// Save Chapters handler
const handleSaveChaptersConfirm = async () => {
// Close confirmation modal and show processing modal
setShowSaveChaptersModal(false);
setShowProcessingModal(true);
setSaveType('chapters');
try {
// Format chapters data for API request - sort by start time first
const chapters = clipSegments
.filter((segment) => segment.chapterTitle && segment.chapterTitle.trim())
.sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically
.map((segment) => ({
chapterTitle: segment.chapterTitle || `Chapter ${segment.id}`,
from: formatDetailedTime(segment.startTime),
to: formatDetailedTime(segment.endTime),
}));
// Allow saving even when no chapters exist (will send empty array)
// Call the onChapterSave function if provided
if (onChapterSave) {
await onChapterSave(chapters);
setShowProcessingModal(false);
if (chapters.length === 0) {
setSuccessMessage('All chapters cleared successfully!');
} else {
setSuccessMessage('Chapters saved successfully!');
}
// Set redirect URL to media page
const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null;
if (mediaId) {
setRedirectUrl(`/view?m=${mediaId}`);
}
setShowSuccessModal(true);
} else {
setErrorMessage('Chapter save function not available');
setShowErrorModal(true);
setShowProcessingModal(false);
}
} catch (error) {
logger.error('Error saving chapters:', error);
setShowProcessingModal(false);
// Set error message and show error modal
const errorMsg = error instanceof Error ? error.message : 'An error occurred while saving chapters';
logger.debug('Save chapters error (exception):', errorMsg);
setErrorMessage(errorMsg);
setShowErrorModal(true);
}
};
// Auto-scroll and update tooltip position when seeking to a different time
useEffect(() => {
if (scrollContainerRef.current && timelineRef.current && zoomLevel > 1) {
const containerWidth = scrollContainerRef.current.clientWidth;
const timelineWidth = timelineRef.current.clientWidth;
const markerPosition = (currentTime / duration) * timelineWidth;
// Calculate the position where we want the marker to be visible
// (center of the viewport when possible)
const desiredScrollPosition = Math.max(0, markerPosition - containerWidth / 2);
// Smooth scroll to the desired position
scrollContainerRef.current.scrollTo({
left: desiredScrollPosition,
behavior: 'smooth',
});
// Update tooltip position to stay with the marker
const rect = timelineRef.current.getBoundingClientRect();
// Calculate the visible position of the marker after scrolling
const containerRect = scrollContainerRef.current.getBoundingClientRect();
const visibleTimelineLeft = rect.left - scrollContainerRef.current.scrollLeft;
const markerX = visibleTimelineLeft + (currentTimePercent / 100) * rect.width;
// Only update if we have a tooltip showing
if (selectedSegmentId !== null || showEmptySpaceTooltip) {
setTooltipPosition({
x: markerX,
y: rect.top - 10,
});
setClickedTime(currentTime);
}
}
}, [currentTime, zoomLevel, duration, selectedSegmentId, showEmptySpaceTooltip, currentTimePercent]);
// Effect to check active segment boundaries during playback - DISABLED for continuous playback
useEffect(() => {
// Boundary checking disabled - allow continuous playback through all segments
logger.debug('Segment boundary checking disabled - continuous playback enabled');
return;
}, [activeSegment, isPlayingSegment, continuePastBoundary, clipSegments]);
// Update display time and check for transitions between segments and empty spaces
useEffect(() => {
// Always update display time to match current video time
if (videoRef.current) {
// Always update display time when current time changes (both playing and paused)
setDisplayTime(currentTime);
// If video is playing, also update the tooltip and perform segment checks
if (!videoRef.current.paused) {
// Also update clicked time to keep them in sync when playing
// This ensures correct time is shown when pausing
setClickedTime(currentTime);
if (selectedSegmentId !== null) {
setIsPlayingSegment(true);
}
// While playing, continuously check if we're in a segment or empty space
// to update the tooltip accordingly, regardless of where we started playing
// Check if we're in any segment at current time
const segmentAtCurrentTime = clipSegments.find(
(seg) => currentTime >= seg.startTime && currentTime <= seg.endTime
);
// Update tooltip position based on current time percentage
const newTimePercent = (currentTime / duration) * 100;
if (timelineRef.current) {
const timelineWidth = timelineRef.current.offsetWidth;
const markerX = (newTimePercent / 100) * timelineWidth;
setTooltipPosition({
x: markerX,
y: timelineRef.current.getBoundingClientRect().top - 10,
});
}
// Check for the special "continue past segment" state in sessionStorage
const isContinuingPastSegment = sessionStorage.getItem('continuingPastSegment') === 'true';
// If we're in a segment now
if (segmentAtCurrentTime) {
// Get video element reference for boundary checks
const video = videoRef.current;
// Special check for virtual segments (cutaway playback)
// If we have an active virtual segment (negative ID) and we're in a regular segment now,
// we need to STOP at the start of this segment - that's the boundary of our cutaway
const isPlayingVirtualSegment = activeSegment && activeSegment.id < 0 && isPlayingSegment;
// If the active segment is different from the current segment and it's not a virtual segment
// and we're not in "continue past boundary" mode, set this segment as the active segment
if (
activeSegment?.id !== segmentAtCurrentTime.id &&
!isPlayingVirtualSegment &&
!isContinuingPastSegment &&
!continuePastBoundary
) {
// We've entered a new segment during normal playback
logger.debug(
`Entered a new segment during playback: ${segmentAtCurrentTime.id}, setting as active`
);
setActiveSegment(segmentAtCurrentTime);
setSelectedSegmentId(segmentAtCurrentTime.id);
setShowEmptySpaceTooltip(false);
// Reset continuation flags to ensure boundary detection works for this new segment
setContinuePastBoundary(false);
sessionStorage.removeItem('continuingPastSegment');
}
// If we're playing a virtual segment and enter a real segment, we've reached our boundary
// We should stop playback
if (isPlayingVirtualSegment && video && segmentAtCurrentTime) {
logger.debug(
`CUTAWAY BOUNDARY REACHED: Current position ${formatDetailedTime(
video.currentTime
)} at segment ${segmentAtCurrentTime.id} - STOPPING at boundary ${formatDetailedTime(
segmentAtCurrentTime.startTime
)}`
);
video.pause();
// Force exact time position with high precision and multiple attempts
setTimeout(() => {
if (videoRef.current) {
// First seek directly to exact start time, no offset
videoRef.current.currentTime = segmentAtCurrentTime.startTime;
// Update UI immediately to match video position
onSeek(segmentAtCurrentTime.startTime);
// Also update tooltip time displays
setDisplayTime(segmentAtCurrentTime.startTime);
setClickedTime(segmentAtCurrentTime.startTime);
// Reset continuePastBoundary when reaching a segment boundary
setContinuePastBoundary(false);
// Update tooltip to show segment tooltip at boundary
setSelectedSegmentId(segmentAtCurrentTime.id);
setShowEmptySpaceTooltip(false);
// Force multiple adjustments to ensure exact precision
const verifyPosition = () => {
if (videoRef.current) {
// Always force the exact time in every verification
videoRef.current.currentTime = segmentAtCurrentTime.startTime;
// Make sure we update the UI to reflect the corrected position
onSeek(segmentAtCurrentTime.startTime);
// Update the displayTime and clickedTime state to match exact position
setDisplayTime(segmentAtCurrentTime.startTime);
setClickedTime(segmentAtCurrentTime.startTime);
logger.debug(
`Position corrected to exact segment boundary: ${formatDetailedTime(
videoRef.current.currentTime
)} (target: ${formatDetailedTime(segmentAtCurrentTime.startTime)})`
);
}
};
// Apply multiple correction attempts with increasing delays
setTimeout(verifyPosition, 10); // Immediate correction
setTimeout(verifyPosition, 20); // First correction
setTimeout(verifyPosition, 50); // Second correction
setTimeout(verifyPosition, 100); // Third correction
setTimeout(verifyPosition, 200); // Final correction
// Also add event listeners to ensure position is corrected whenever video state changes
videoRef.current.addEventListener('seeked', verifyPosition);
videoRef.current.addEventListener('canplay', verifyPosition);
videoRef.current.addEventListener('waiting', verifyPosition);
// Remove these event listeners after a short time
setTimeout(() => {
if (videoRef.current) {
videoRef.current.removeEventListener('seeked', verifyPosition);
videoRef.current.removeEventListener('canplay', verifyPosition);
videoRef.current.removeEventListener('waiting', verifyPosition);
}
}, 300);
}
}, 10);
setIsPlayingSegment(false);
setActiveSegment(null);
return; // Exit early, we've handled this case
}
// Only update active segment if we're not in "continue past segment" mode
// or if we're in a virtual cutaway segment
const continuingPastSegment =
(activeSegment === null && isPlayingSegment === true) ||
isContinuingPastSegment ||
isPlayingVirtualSegment;
if (continuingPastSegment) {
// We're in the special case where we're continuing past a segment boundary
// or playing a cutaway area
// Just update the tooltip, but don't reactivate boundary checking
if (selectedSegmentId !== segmentAtCurrentTime.id || showEmptySpaceTooltip) {
logger.debug(
'Tooltip updated for segment during continued playback:',
segmentAtCurrentTime.id,
isPlayingVirtualSegment ? '(cutaway playback - keeping virtual segment)' : ''
);
setSelectedSegmentId(segmentAtCurrentTime.id);
setShowEmptySpaceTooltip(false);
// If we're in a different segment now, clear the continuation flag
// but only if it's not the same segment we were in before
// AND we're not playing a cutaway area
if (
!isPlayingVirtualSegment &&
sessionStorage.getItem('lastSegmentId') !== segmentAtCurrentTime.id.toString()
) {
logger.debug('Moved to a different segment - ending continuation mode');
sessionStorage.removeItem('continuingPastSegment');
}
}
} else {
// Normal case - update both tooltip and active segment
if (activeSegment?.id !== segmentAtCurrentTime.id || showEmptySpaceTooltip) {
logger.debug('Playback moved into segment:', segmentAtCurrentTime.id);
setSelectedSegmentId(segmentAtCurrentTime.id);
setActiveSegment(segmentAtCurrentTime);
setShowEmptySpaceTooltip(false);
// Store the current segment ID for comparison later
sessionStorage.setItem('lastSegmentId', segmentAtCurrentTime.id.toString());
}
}
}
// If we're in empty space now
else {
// Check if we need to change the tooltip (we were in a segment before)
if (activeSegment !== null || !showEmptySpaceTooltip) {
logger.debug('Playback moved to empty space');
setSelectedSegmentId(null);
setActiveSegment(null);
// Calculate available space for new segment before showing tooltip
const availableSpace = calculateAvailableSpace(currentTime);
setAvailableSegmentDuration(availableSpace);
// Show empty space tooltip if there's enough space
if (availableSpace >= 0.5) {
setShowEmptySpaceTooltip(true);
logger.debug('Empty space with available duration:', availableSpace);
} else {
setShowEmptySpaceTooltip(false);
}
}
}
} else if (videoRef.current.paused && isPlayingSegment) {
// When just paused from playing state, update display time to show the actual stopped position
setDisplayTime(currentTime);
setClickedTime(currentTime);
setIsPlayingSegment(false);
// Log the stopping point
logger.debug('Video paused at:', formatDetailedTime(currentTime));
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTime, isPlayingSegment, activeSegment, selectedSegmentId, clipSegments]);
// Close zoom dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (isZoomDropdownOpen && !target.closest('.zoom-dropdown-container')) {
setIsZoomDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isZoomDropdownOpen]);
// Listen for segment updates and trigger auto-save
useEffect(() => {
const handleSegmentUpdate = (event: CustomEvent) => {
const { recordHistory, fromAutoSave } = event.detail;
logger.debug('handleSegmentUpdate called, recordHistory:', recordHistory, 'fromAutoSave:', fromAutoSave);
// Only auto-save when history is recorded and not loading from auto-save
if (recordHistory && !fromAutoSave) {
logger.debug('Calling scheduleAutoSave from handleSegmentUpdate');
scheduleAutoSave();
}
};
const handleSegmentDragEnd = () => {
// Trigger auto-save when drag operations end
scheduleAutoSave();
};
const handleTrimUpdate = (event: CustomEvent) => {
const { recordHistory } = event.detail;
// Only auto-save when history is recorded (i.e., after trim operations complete)
if (recordHistory) {
scheduleAutoSave();
}
};
document.addEventListener('update-segments', handleSegmentUpdate as EventListener);
document.addEventListener('segment-drag-end', handleSegmentDragEnd);
document.addEventListener('update-trim', handleTrimUpdate as EventListener);
document.addEventListener('delete-segment', scheduleAutoSave);
document.addEventListener('split-segment', scheduleAutoSave);
document.addEventListener('undo-redo-autosave', scheduleAutoSave);
return () => {
logger.debug('Cleaning up auto-save event listeners...');
document.removeEventListener('update-segments', handleSegmentUpdate as EventListener);
document.removeEventListener('segment-drag-end', handleSegmentDragEnd);
document.removeEventListener('update-trim', handleTrimUpdate as EventListener);
document.removeEventListener('delete-segment', scheduleAutoSave);
document.removeEventListener('split-segment', scheduleAutoSave);
document.removeEventListener('undo-redo-autosave', scheduleAutoSave);
// Clear any pending auto-save timer
if (autoSaveTimerRef.current) {
logger.debug('Clearing auto-save timer in cleanup:', autoSaveTimerRef.current);
clearTimeout(autoSaveTimerRef.current);
}
};
}, [scheduleAutoSave]);
// Perform initial auto-save when component mounts with segments
useEffect(() => {
if (clipSegments.length > 0 && !lastAutoSaveTime) {
// Perform initial auto-save after a short delay
setTimeout(() => {
performAutoSave();
}, 500);
}
}, [lastAutoSaveTime, performAutoSave]);
// Load saved segments from MEDIA_DATA on component mount
useEffect(() => {
const loadSavedSegments = () => {
// Get savedSegments directly from window.MEDIA_DATA
let savedData = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) || null;
try {
if (savedData && savedData.chapters && savedData.chapters.length > 0) {
logger.debug('Found saved segments:', savedData);
// Convert the saved segments to the format expected by the component
const convertedSegments: Segment[] = savedData.chapters.map((seg: any , index: number) => ({
id: Date.now() + index, // Generate unique IDs
chapterTitle: seg.chapterTitle || `Chapter ${index + 1}`,
startTime: parseTimeString(seg.startTime),
endTime: parseTimeString(seg.endTime),
}));
// Dispatch event to update segments
const updateEvent = new CustomEvent('update-segments', {
detail: {
segments: convertedSegments,
recordHistory: false, // Don't record loading saved segments in history
fromAutoSave: true,
},
});
document.dispatchEvent(updateEvent);
// Update the last auto-save time
if (savedData.updated_at) {
const date = new Date(savedData.updated_at);
const formattedTime = date
.toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
.replace(',', '');
setLastAutoSaveTime(formattedTime);
}
} else {
logger.debug('No saved segments found');
}
} catch (error) {
console.error('Error loading saved segments:', error);
}
};
// Helper function to parse time string "HH:MM:SS.mmm" to seconds
const parseTimeString = (timeStr: string): number => {
const parts = timeStr.split(':');
if (parts.length !== 3) return 0;
const hours = parseInt(parts[0]) || 0;
const minutes = parseInt(parts[1]) || 0;
const secondsParts = parts[2].split('.');
const seconds = parseInt(secondsParts[0]) || 0;
const milliseconds = parseInt(secondsParts[1]) || 0;
return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000;
};
// Load saved segments after a short delay to ensure component is ready
setTimeout(loadSavedSegments, 100);
}, []); // Run only once on mount
// Global click handler to close tooltips when clicking outside
useEffect(() => {
// Remove the global click handler that closes tooltips
// This keeps the popup always visible, even when clicking outside the timeline
// Keeping the dependency array to avoid linting errors
return () => {};
}, [selectedSegmentId, showEmptySpaceTooltip, isPlayingSegment]);
// Initialize drag handlers for trim handles
useEffect(() => {
const leftHandle = leftHandleRef.current;
const rightHandle = rightHandleRef.current;
const timeline = timelineRef.current;
if (!leftHandle || !rightHandle || !timeline) return;
const initDrag = (isLeft: boolean) => (e: MouseEvent) => {
e.preventDefault();
const timelineRect = timeline.getBoundingClientRect();
let isDragging = true;
let finalTime = isLeft ? trimStart : trimEnd; // Track the final time for history recording
// Use custom events to indicate drag state
const createCustomEvent = (type: string) => {
return new CustomEvent('trim-handle-event', {
detail: { type, isStart: isLeft },
});
};
// Dispatch start drag event to signal not to record history during drag
document.dispatchEvent(createCustomEvent('drag-start'));
const onMouseMove = (moveEvent: MouseEvent) => {
if (!isDragging) return;
const timelineWidth = timelineRect.width;
const position = Math.max(0, Math.min(1, (moveEvent.clientX - timelineRect.left) / timelineWidth));
const newTime = position * duration;
// Store position globally for iOS Safari
if (typeof window !== 'undefined') {
window.lastSeekedPosition = newTime;
}
if (isLeft) {
if (newTime < trimEnd) {
// Don't record in history during drag - this avoids multiple history entries
document.dispatchEvent(
new CustomEvent('update-trim', {
detail: {
time: newTime,
isStart: true,
recordHistory: false,
},
})
);
finalTime = newTime;
}
} else {
if (newTime > trimStart) {
// Don't record in history during drag - this avoids multiple history entries
document.dispatchEvent(
new CustomEvent('update-trim', {
detail: {
time: newTime,
isStart: false,
recordHistory: false,
},
})
);
finalTime = newTime;
}
}
};
const onMouseUp = () => {
isDragging = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
// Now record the final position in history with action type
if (isLeft) {
// Final update with history recording
document.dispatchEvent(
new CustomEvent('update-trim', {
detail: {
time: finalTime,
isStart: true,
recordHistory: true,
action: 'adjust_trim_start',
},
})
);
} else {
document.dispatchEvent(
new CustomEvent('update-trim', {
detail: {
time: finalTime,
isStart: false,
recordHistory: true,
action: 'adjust_trim_end',
},
})
);
}
// Dispatch end drag event
document.dispatchEvent(createCustomEvent('drag-end'));
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
leftHandle.addEventListener('mousedown', initDrag(true));
rightHandle.addEventListener('mousedown', initDrag(false));
return () => {
leftHandle.removeEventListener('mousedown', initDrag(true));
rightHandle.removeEventListener('mousedown', initDrag(false));
};
}, [duration, trimStart, trimEnd, onTrimStartChange, onTrimEndChange]);
// Render 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>) => {
// Remove the check that prevents interaction during preview mode
// This allows users to click and jump in the timeline while previewing
if (!timelineRef.current || !scrollContainerRef.current) return;
// If on mobile device and video hasn't been initialized, don't handle timeline clicks
if (isIOSUninitialized) {
return;
}
// Check if video is globally playing before the click
const wasPlaying = videoRef.current && !videoRef.current.paused;
logger.debug('Video was playing before timeline click:', wasPlaying);
// Reset continuation flag when clicking on timeline - ensures proper boundary detection
setContinuePastBoundary(false);
const rect = timelineRef.current.getBoundingClientRect();
// Account for scroll position when calculating the click position
let position;
if (zoomLevel > 1) {
// When zoomed, we need to account for the scroll position
const scrollLeft = scrollContainerRef.current.scrollLeft;
const totalWidth = timelineRef.current.clientWidth;
position = (e.clientX - rect.left + scrollLeft) / totalWidth;
} else {
// Normal calculation for 1x zoom
position = (e.clientX - rect.left) / rect.width;
}
const newTime = position * duration;
// Log the position for debugging
logger.debug(
'Timeline clicked at:',
formatDetailedTime(newTime),
'distance from end:',
formatDetailedTime(duration - newTime)
);
// Store position globally for iOS Safari (this is critical for first-time visits)
if (typeof window !== 'undefined') {
window.lastSeekedPosition = newTime;
}
// Seek to the clicked position immediately for all clicks
onSeek(newTime);
// Always update both clicked time and display time for tooltip actions
setClickedTime(newTime);
setDisplayTime(newTime);
// Find if we clicked in a segment with a small tolerance for boundaries
const segmentAtClickedTime = clipSegments.find((seg) => {
// Standard check for being inside a segment
const isInside = newTime >= seg.startTime && newTime <= seg.endTime;
// Additional checks for being exactly at the start or end boundary (with small tolerance)
const isAtStart = Math.abs(newTime - seg.startTime) < 0.01;
const isAtEnd = Math.abs(newTime - seg.endTime) < 0.01;
return isInside || isAtStart || isAtEnd;
});
// Handle active segment assignment for boundary checking
if (segmentAtClickedTime) {
setActiveSegment(segmentAtClickedTime);
}
// Resume playback based on the current mode
if (videoRef.current) {
// Special handling for segments playback mode
if (isPlayingSegments && wasPlaying) {
// Update the current segment index if we clicked into a segment
if (segmentAtClickedTime) {
const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
const targetSegmentIndex = orderedSegments.findIndex((seg) => seg.id === segmentAtClickedTime.id);
if (targetSegmentIndex !== -1) {
// Dispatch a custom event to update the current segment index
const updateSegmentIndexEvent = new CustomEvent('update-segment-index', {
detail: { segmentIndex: targetSegmentIndex },
});
document.dispatchEvent(updateSegmentIndexEvent);
logger.debug(
`Segments playback mode: updating segment index to ${targetSegmentIndex} for timeline click in segment ${segmentAtClickedTime.id}`
);
}
}
logger.debug('Segments playback mode: resuming playback after timeline click');
videoRef.current
.play()
.then(() => {
setIsPlayingSegment(true);
logger.debug('Resumed segments playback after timeline seeking');
})
.catch((err) => {
console.error('Error resuming segments playback:', err);
setIsPlayingSegment(false);
});
}
// Resume playback if it was playing before (but not during segments playback)
else if (wasPlaying && !isPlayingSegments) {
logger.debug('Resuming playback after timeline click');
videoRef.current
.play()
.then(() => {
setIsPlayingSegment(true);
logger.debug('Resumed playback after seeking');
})
.catch((err) => {
console.error('Error resuming playback:', err);
setIsPlayingSegment(false);
});
}
}
// Only process tooltip display if clicked on the timeline background or thumbnails, not on other UI elements
if (e.target === timelineRef.current) {
// Check if there's a segment at the clicked position
if (segmentAtClickedTime) {
setSelectedSegmentId(segmentAtClickedTime.id);
setShowEmptySpaceTooltip(false);
} else {
// We're in a cutaway area - always show tooltip
setSelectedSegmentId(null);
// Calculate the available space for a new segment
const availableSpace = calculateAvailableSpace(newTime);
setAvailableSegmentDuration(availableSpace);
// Calculate and set tooltip position correctly for zoomed timeline
let xPos;
if (zoomLevel > 1) {
// For zoomed timeline, calculate the visible position
const visibleTimelineLeft = rect.left - scrollContainerRef.current.scrollLeft;
const clickPosPercent = newTime / duration;
xPos = visibleTimelineLeft + clickPosPercent * rect.width;
} else {
// For 1x zoom, use the client X
xPos = e.clientX;
}
setTooltipPosition({
x: xPos,
y: rect.top - 10, // Position tooltip above the timeline
});
// Always show the empty space tooltip in cutaway areas
setShowEmptySpaceTooltip(true);
// Log the cutaway area details
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
const prevSegment = [...sortedSegments].reverse().find((seg) => seg.endTime < newTime);
const nextSegment = sortedSegments.find((seg) => seg.startTime > newTime);
logger.debug('Clicked in cutaway area:', {
position: formatDetailedTime(newTime),
availableSpace: formatDetailedTime(availableSpace),
prevSegmentEnd: prevSegment ? formatDetailedTime(prevSegment.endTime) : 'none',
nextSegmentStart: nextSegment ? formatDetailedTime(nextSegment.startTime) : 'none',
});
}
}
};
// Handle segment resize - works with both mouse and touch events
const handleSegmentResize = (segmentId: number, isLeft: boolean) => (e: React.MouseEvent | React.TouchEvent) => {
// Remove the check that prevents interaction during preview mode
// This allows users to resize segments while previewing
e.preventDefault();
e.stopPropagation(); // Prevent triggering parent's events
if (!timelineRef.current) return;
const timelineRect = timelineRef.current.getBoundingClientRect();
const timelineWidth = timelineRect.width;
// Find the segment that's being resized
const segment = clipSegments.find((seg) => seg.id === segmentId);
if (!segment) return;
const originalStartTime = segment.startTime;
const originalEndTime = segment.endTime;
// Store the original segment state to compare after dragging
const segmentBeforeDrag = { ...segment };
// Add a visual indicator that we're in resize mode (for mouse devices)
document.body.style.cursor = 'ew-resize';
// Add a temporary overlay to help with dragging outside the element
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100vw';
overlay.style.height = '100vh';
overlay.style.zIndex = '1000';
overlay.style.cursor = 'ew-resize';
document.body.appendChild(overlay);
// Track dragging state and final positions
let isDragging = true;
let finalStartTime = originalStartTime;
let finalEndTime = originalEndTime;
// Dispatch an event to signal drag start
document.dispatchEvent(
new CustomEvent('segment-drag-start', {
detail: { segmentId },
})
);
// Keep the tooltip visible during drag
// Function to handle both mouse and touch movements
const handleDragMove = (clientX: number) => {
if (!isDragging || !timelineRef.current) return;
const updatedTimelineRect = timelineRef.current.getBoundingClientRect();
const position = Math.max(0, Math.min(1, (clientX - updatedTimelineRect.left) / updatedTimelineRect.width));
const newTime = position * duration;
// Check if the current marker position intersects with where the segment will be
const currentSegmentStart = isLeft ? newTime : originalStartTime;
const currentSegmentEnd = isLeft ? originalEndTime : newTime;
const isMarkerInSegment = currentTime >= currentSegmentStart && currentTime <= currentSegmentEnd;
// Update tooltip based on marker intersection
if (isMarkerInSegment) {
// Show segment tooltip if marker is inside the segment
setSelectedSegmentId(segmentId);
setShowEmptySpaceTooltip(false);
} else {
// Show cutaway tooltip if marker is outside the segment
setSelectedSegmentId(null);
// Calculate available space for cutaway tooltip
const availableSpace = calculateAvailableSpace(currentTime);
setAvailableSegmentDuration(availableSpace);
setShowEmptySpaceTooltip(true);
}
// Find neighboring segments (exclude the current one)
const otherSegments = clipSegments.filter((seg) => seg.id !== segmentId);
// Calculate new start/end times based on drag direction
let newStartTime = originalStartTime;
let newEndTime = originalEndTime;
if (isLeft) {
// Dragging left handle - adjust start time
newStartTime = Math.min(newTime, originalEndTime - 0.5);
// Find the closest left neighbor
const leftNeighbors = otherSegments
.filter((seg) => seg.endTime <= originalStartTime)
.sort((a, b) => b.endTime - a.endTime);
const leftNeighbor = leftNeighbors[0];
// Prevent overlapping with left neighbor
if (leftNeighbor && newStartTime < leftNeighbor.endTime) {
newStartTime = leftNeighbor.endTime;
}
// Snap to the nearest segment with a small threshold
const snapThreshold = 0.3; // seconds
if (leftNeighbor && Math.abs(newStartTime - leftNeighbor.endTime) < snapThreshold) {
newStartTime = leftNeighbor.endTime;
}
// Update final value for history recording
finalStartTime = newStartTime;
} else {
// Dragging right handle - adjust end time
newEndTime = Math.max(newTime, originalStartTime + 0.5);
// Find the closest right neighbor
const rightNeighbors = otherSegments
.filter((seg) => seg.startTime >= originalEndTime)
.sort((a, b) => a.startTime - b.startTime);
const rightNeighbor = rightNeighbors[0];
// Prevent overlapping with right neighbor
if (rightNeighbor && newEndTime > rightNeighbor.startTime) {
newEndTime = rightNeighbor.startTime;
}
// Snap to the nearest segment with a small threshold
const snapThreshold = 0.3; // seconds
if (rightNeighbor && Math.abs(newEndTime - rightNeighbor.startTime) < snapThreshold) {
newEndTime = rightNeighbor.startTime;
}
// Update final value for history recording
finalEndTime = newEndTime;
}
// Create a new segments array with the updated segment
const updatedSegments = clipSegments.map((seg) => {
if (seg.id === segmentId) {
return {
...seg,
startTime: newStartTime,
endTime: newEndTime,
};
}
return seg;
});
// Create a custom event to update the segments WITHOUT recording in history during drag
const updateEvent = new CustomEvent('update-segments', {
detail: {
segments: updatedSegments,
recordHistory: false, // Don't record intermediate states
},
});
document.dispatchEvent(updateEvent);
// During dragging, check if the current tooltip needs to be updated based on segment position
if (selectedSegmentId === segmentId && videoRef.current) {
const currentTime = videoRef.current.currentTime;
const segment = updatedSegments.find((seg) => seg.id === segmentId);
if (segment) {
// Check if playhead position is now outside the segment after dragging
const isInsideSegment = currentTime >= segment.startTime && currentTime <= segment.endTime;
// Log the current position information for debugging
logger.debug(
`During drag - playhead at ${formatDetailedTime(currentTime)} is ${
isInsideSegment ? 'inside' : 'outside'
} segment (${formatDetailedTime(segment.startTime)} - ${formatDetailedTime(segment.endTime)})`
);
if (!isInsideSegment && isPlayingSegment) {
logger.debug('Playhead position is outside segment after dragging - updating tooltip');
// Stop playback if we were playing and dragged the segment away from playhead
videoRef.current.pause();
setIsPlayingSegment(false);
setActiveSegment(null);
}
// Update display time to stay in bounds of the segment
if (currentTime < segment.startTime) {
logger.debug(
`Adjusting display time to segment start: ${formatDetailedTime(segment.startTime)}`
);
setDisplayTime(segment.startTime);
// Update UI state to reflect that playback will be from segment start
setClickedTime(segment.startTime);
} else if (currentTime > segment.endTime) {
logger.debug(`Adjusting display time to segment end: ${formatDetailedTime(segment.endTime)}`);
setDisplayTime(segment.endTime);
// Update UI state to reflect that playback will be from segment end
setClickedTime(segment.endTime);
}
}
}
};
// Function to handle the end of dragging (for both mouse and touch)
const handleDragEnd = () => {
if (!isDragging) return;
isDragging = false;
// Clean up event listeners for both mouse and touch
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
document.removeEventListener('touchcancel', handleTouchEnd);
// Reset styles
document.body.style.cursor = '';
if (document.body.contains(overlay)) {
document.body.removeChild(overlay);
}
// Record the final position in history as a single action
const finalSegments = clipSegments.map((seg) => {
if (seg.id === segmentId) {
return {
...seg,
startTime: finalStartTime,
endTime: finalEndTime,
};
}
return seg;
});
// Now we can create a history record for the complete drag operation
const actionType = isLeft ? 'adjust_segment_start' : 'adjust_segment_end';
document.dispatchEvent(
new CustomEvent('update-segments', {
detail: {
segments: finalSegments,
recordHistory: true,
action: actionType,
},
})
);
// After drag is complete, do a final check to see if playhead is inside the segment
if (selectedSegmentId === segmentId && videoRef.current) {
const currentTime = videoRef.current.currentTime;
const segment = finalSegments.find((seg) => seg.id === segmentId);
if (segment) {
const isInsideSegment = currentTime >= segment.startTime && currentTime <= segment.endTime;
logger.debug(
`Drag complete - playhead at ${formatDetailedTime(currentTime)} is ${
isInsideSegment ? 'inside' : 'outside'
} segment (${formatDetailedTime(segment.startTime)} - ${formatDetailedTime(segment.endTime)})`
);
// Check if playhead status changed during drag
const wasInsideSegmentBefore =
currentTime >= segmentBeforeDrag.startTime && currentTime <= segmentBeforeDrag.endTime;
logger.debug(
`Playhead was ${
wasInsideSegmentBefore ? 'inside' : 'outside'
} segment before drag, now ${isInsideSegment ? 'inside' : 'outside'}`
);
// Update UI elements based on segment position
if (!isInsideSegment) {
// If we were playing and the playhead is now outside the segment, stop playback
if (isPlayingSegment) {
videoRef.current.pause();
setIsPlayingSegment(false);
setActiveSegment(null);
setContinuePastBoundary(false);
logger.debug('Stopped playback because playhead is outside segment after drag completion');
}
// Update display time to be within the segment's bounds
if (currentTime < segment.startTime) {
logger.debug(
`Final adjustment - setting display time to segment start: ${formatDetailedTime(
segment.startTime
)}`
);
setDisplayTime(segment.startTime);
setClickedTime(segment.startTime);
} else if (currentTime > segment.endTime) {
logger.debug(
`Final adjustment - setting display time to segment end: ${formatDetailedTime(
segment.endTime
)}`
);
setDisplayTime(segment.endTime);
setClickedTime(segment.endTime);
}
}
// Special case: playhead was outside segment before, but now it's inside - can start playback
else if (!wasInsideSegmentBefore && isInsideSegment) {
logger.debug('Playhead moved INTO segment during drag - can start playback');
setActiveSegment(segment);
}
// Another special case: playhead was inside segment before, but now is also inside but at a different position
else if (
wasInsideSegmentBefore &&
isInsideSegment &&
(segment.startTime !== segmentBeforeDrag.startTime ||
segment.endTime !== segmentBeforeDrag.endTime)
) {
logger.debug(
'Segment boundaries changed while playhead remained inside - updating activeSegment'
);
// Update the active segment reference to ensure boundary detection works with new bounds
setActiveSegment(segment);
}
}
}
};
// Mouse-specific event handlers
const handleMouseMove = (moveEvent: MouseEvent) => {
handleDragMove(moveEvent.clientX);
};
const handleMouseUp = () => {
handleDragEnd();
};
// Touch-specific event handlers
const handleTouchMove = (moveEvent: TouchEvent) => {
if (moveEvent.touches.length > 0) {
moveEvent.preventDefault(); // Prevent scrolling while dragging
handleDragMove(moveEvent.touches[0].clientX);
}
};
const handleTouchEnd = () => {
handleDragEnd();
};
// Register event listeners for both mouse and touch
document.addEventListener('mousemove', handleMouseMove, {
passive: false,
});
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('touchmove', handleTouchMove, {
passive: false,
});
document.addEventListener('touchend', handleTouchEnd);
document.addEventListener('touchcancel', handleTouchEnd);
};
// Handle segment click to show the tooltip
const handleSegmentClick = (segmentId: number) => (e: React.MouseEvent) => {
// Remove the check that prevents interaction during preview mode
// This allows users to click segments while previewing
// Don't show tooltip if clicked on handle
if ((e.target as HTMLElement).classList.contains('clip-segment-handle')) {
return;
}
e.preventDefault();
e.stopPropagation();
logger.debug('Segment clicked:', segmentId);
// Reset continuation flag when selecting a segment - ensures proper boundary detection
setContinuePastBoundary(false);
// Check if video is currently playing before clicking
const wasPlaying = videoRef.current && !videoRef.current.paused;
logger.debug('seekVideo: Was playing before:', wasPlaying);
// Set the current segment as selected
setSelectedSegmentId(segmentId);
// Find the segment in our data
const segment = clipSegments.find((seg) => seg.id === segmentId);
if (!segment) return;
// Find the segment element in the DOM
const segmentElement = e.currentTarget as HTMLElement;
const segmentRect = segmentElement.getBoundingClientRect();
// Calculate relative click position within the segment (0 to 1)
const relativeX = (e.clientX - segmentRect.left) / segmentRect.width;
// Convert to time based on segment's start and end times
const clickTime = segment.startTime + relativeX * (segment.endTime - segment.startTime);
// Ensure time is within segment bounds
const boundedTime = Math.max(segment.startTime, Math.min(segment.endTime, clickTime));
// Set both clicked time and display time for UI
setClickedTime(boundedTime);
setDisplayTime(boundedTime);
// Check if the video's current time is inside or outside the segment
// This helps with updating the tooltip correctly after dragging operations
if (videoRef.current) {
const currentVideoTime = videoRef.current.currentTime;
const isPlayheadInsideSegment =
currentVideoTime >= segment.startTime && currentVideoTime <= segment.endTime;
logger.debug(
`Segment click - playhead at ${formatDetailedTime(currentVideoTime)} is ${
isPlayheadInsideSegment ? 'inside' : 'outside'
} segment (${formatDetailedTime(segment.startTime)} - ${formatDetailedTime(segment.endTime)})`
);
// If playhead is outside the segment, update the display time to segment boundary
if (!isPlayheadInsideSegment) {
// Adjust the display time based on which end is closer to the playhead
if (Math.abs(currentVideoTime - segment.startTime) < Math.abs(currentVideoTime - segment.endTime)) {
// Playhead is closer to segment start
logger.debug(
`Playhead outside segment - adjusting to segment start: ${formatDetailedTime(
segment.startTime
)}`
);
setDisplayTime(segment.startTime);
// Don't update clickedTime here since we already set it to the clicked position
} else {
// Playhead is closer to segment end
logger.debug(
`Playhead outside segment - adjusting to segment end: ${formatDetailedTime(segment.endTime)}`
);
setDisplayTime(segment.endTime);
// Don't update clickedTime here since we already set it to the clicked position
}
}
}
// Seek to this position (this will update the video's current time)
onSeek(boundedTime);
// Handle playback continuation based on the current mode
if (videoRef.current) {
// Special handling for segments playback mode
if (isPlayingSegments && wasPlaying) {
// Update the current segment index for segments playback mode
const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
const targetSegmentIndex = orderedSegments.findIndex((seg) => seg.id === segmentId);
if (targetSegmentIndex !== -1) {
// Dispatch a custom event to update the current segment index
const updateSegmentIndexEvent = new CustomEvent('update-segment-index', {
detail: { segmentIndex: targetSegmentIndex },
});
document.dispatchEvent(updateSegmentIndexEvent);
logger.debug(
`Segments playback mode: updating segment index to ${targetSegmentIndex} for segment ${segmentId}`
);
}
// In segments playback mode, we want to continue the segments playback from the new position
// The segments playback will naturally handle continuing to the next segments
logger.debug('Segments playback mode: continuing playback from new position');
videoRef.current
.play()
.then(() => {
setIsPlayingSegment(true);
logger.debug('Continued segments playback after segment click');
})
.catch((err) => {
console.error('Error continuing segments playback after segment click:', err);
});
}
// If video was playing before, ensure it continues playing (but not in segments mode)
else if (wasPlaying && !isPlayingSegments) {
// Set current segment as active segment for boundary checking
setActiveSegment(segment);
// Reset the continuePastBoundary flag when clicking on a segment to ensure boundaries work
setContinuePastBoundary(false);
// Continue playing from the new position
videoRef.current
.play()
.then(() => {
setIsPlayingSegment(true);
logger.debug('Continued preview playback after segment click');
})
.catch((err) => {
console.error('Error resuming playback after segment click:', err);
});
}
}
// Calculate tooltip position directly above click point
const tooltipX = e.clientX;
const tooltipY = segmentRect.top - 10;
setTooltipPosition({
x: tooltipX,
y: tooltipY,
});
// Auto-scroll to center the clicked position for zoomed timeline
if (zoomLevel > 1 && timelineRef.current && scrollContainerRef.current) {
const timelineRect = timelineRef.current.getBoundingClientRect();
const timelineWidth = timelineRef.current.clientWidth;
const containerWidth = scrollContainerRef.current.clientWidth;
// Calculate pixel position of clicked time
const clickedPosPixel = (boundedTime / duration) * timelineWidth;
// Center the view on the clicked position
const targetScrollLeft = Math.max(0, clickedPosPixel - containerWidth / 2);
// Smooth scroll to the clicked point
scrollContainerRef.current.scrollTo({
left: targetScrollLeft,
behavior: 'smooth',
});
// Update tooltip position after scrolling completes
setTimeout(() => {
if (timelineRef.current && scrollContainerRef.current) {
// Calculate new position based on viewport
const updatedRect = timelineRef.current.getBoundingClientRect();
const timePercent = boundedTime / duration;
const newPosition =
timePercent * timelineWidth - scrollContainerRef.current.scrollLeft + updatedRect.left;
setTooltipPosition({
x: newPosition,
y: tooltipY,
});
}
}, 300); // Wait for smooth scrolling to complete
}
// We no longer need a local click handler as we have a global one
// that handles closing tooltips when clicking outside
};
// Show tooltip for the segment
const setShowTooltip = (show: boolean, segmentId: number, x: number, y: number) => {
setSelectedSegmentId(show ? segmentId : null);
setTooltipPosition({ x, y });
};
// Render the clip segments on the timeline
const renderClipSegments = () => {
// Sort segments by start time to ensure correct chronological order
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
return sortedSegments.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">Chapter {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 */}
{isPlayingSegments ? null : (
<>
<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) {
// Allow empty state - clear all UI state
setSelectedSegmentId(null);
setShowEmptySpaceTooltip(false);
setActiveSegment(null);
logger.debug('All segments deleted - entering empty state');
} 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 = () => {
// Simple play handler - just update UI state, no boundary checking
setIsPlaying(true);
setIsPlayingSegment(true);
logger.debug('Continuous playback started from TimelineControls');
};
const handlePause = () => {
logger.debug('Video paused from external control');
setIsPlaying(false);
setIsPlayingSegment(false);
};
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
return () => {
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
};
}, []);
// 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]);
// Note: Removed the conflicting redirect effect - redirect is now handled by cancelRedirect function
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>
<div className="duration-time">
Total Chapters:{' '}
<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 */}
{isPlayingSegments ? null : (
<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()}
{/* Segment Tooltip */}
{selectedSegmentId !== null && (
<div
className={`segment-tooltip two-row-tooltip ${
isPlayingSegments ? 'segments-playback-mode' : ''
}`}
style={{
position: 'absolute',
...constrainTooltipPosition(currentTimePercent),
}}
onClick={(e) => {
if (isPlayingSegments) {
e.stopPropagation();
e.preventDefault();
}
}}
>
{/* Chapter Editor for this segment */}
{selectedSegmentId && (
<div className="tooltip-chapter-editor">
<textarea
className="tooltip-chapter-input"
placeholder="Add Chapter Text"
value={editingChapterTitle}
onChange={(e) => handleChapterTitleChange(e.target.value)}
onBlur={performAutoSave}
onMouseLeave={performAutoSave}
rows={2}
maxLength={200}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => e.stopPropagation()}
/>
</div>
)}
{/* First row with time adjustment buttons */}
<div className="tooltip-row">
<button
className={`tooltip-time-btn ${isPlayingSegments ? 'disabled' : ''}`}
data-tooltip={
isPlayingSegments ? 'Disabled during preview' : 'Seek -50ms (click or hold)'
}
disabled={isPlayingSegments}
{...(!isPlayingSegments ? handleContinuousTimeAdjustment(-0.05) : {})}
style={{
userSelect: 'none',
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none',
touchAction: 'manipulation',
cursor: isPlayingSegments ? 'not-allowed' : 'pointer',
WebkitTapHighlightColor: 'transparent',
}}
>
-50ms
</button>
<div
className={`tooltip-time-display ${isPlayingSegments ? 'disabled' : ''}`}
style={{
pointerEvents: isPlayingSegments ? 'none' : 'auto',
cursor: isPlayingSegments ? 'not-allowed' : 'default',
opacity: isPlayingSegments ? 0.6 : 1,
userSelect: 'none',
WebkitUserSelect: 'none',
}}
onClick={(e) => {
if (isPlayingSegments) {
e.stopPropagation();
e.preventDefault();
}
}}
>
{formatDetailedTime(displayTime)}
</div>
<button
className={`tooltip-time-btn ${isPlayingSegments ? 'disabled' : ''}`}
data-tooltip={
isPlayingSegments ? 'Disabled during preview' : 'Seek +50ms (click or hold)'
}
disabled={isPlayingSegments}
{...(!isPlayingSegments ? handleContinuousTimeAdjustment(0.05) : {})}
style={{
userSelect: 'none',
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none',
touchAction: 'manipulation',
cursor: isPlayingSegments ? 'not-allowed' : 'pointer',
WebkitTapHighlightColor: 'transparent',
}}
>
+50ms
</button>
</div>
{/* Second row with action buttons */}
<div className="tooltip-row tooltip-actions">
<button
className={`tooltip-action-btn delete ${isPlayingSegments ? 'disabled' : ''}`}
data-tooltip={isPlayingSegments ? 'Disabled during preview' : 'Delete segment'}
disabled={isPlayingSegments}
style={{
userSelect: 'none',
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none',
touchAction: 'manipulation',
cursor: isPlayingSegments ? 'not-allowed' : 'pointer',
WebkitTapHighlightColor: 'transparent',
}}
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 ${isPlayingSegments ? 'disabled' : ''}`}
data-tooltip={
isPlayingSegments
? 'Disabled during preview'
: 'Split segment at current position'
}
style={{
userSelect: 'none',
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none',
touchAction: 'manipulation',
cursor: isPlayingSegments ? 'not-allowed' : 'pointer',
WebkitTapHighlightColor: 'transparent',
}}
disabled={isPlayingSegments}
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 ${
isPlayingSegments ? 'disabled' : ''
}`}
data-tooltip={
isPlayingSegments ? 'Disabled during preview' : 'Play segment from beginning'
}
style={{
userSelect: 'none',
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none',
touchAction: 'manipulation',
cursor: isPlayingSegments ? 'not-allowed' : 'pointer',
WebkitTapHighlightColor: 'transparent',
}}
disabled={isPlayingSegments}
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'} ${
isPlayingSegments ? 'disabled' : ''
}`}
data-tooltip={
isPlayingSegments
? 'Disabled during preview'
: isPlaying
? 'Pause playback'
: 'Play from current position'
}
style={{
userSelect: 'none',
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none',
touchAction: 'manipulation',
cursor: isPlayingSegments ? 'not-allowed' : 'pointer',
WebkitTapHighlightColor: 'transparent',
}}
disabled={isPlayingSegments}
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 ${isPlayingSegments ? 'disabled' : ''}`}
style={{
userSelect: 'none',
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none',
touchAction: 'manipulation',
cursor: isPlayingSegments ? 'not-allowed' : 'pointer',
WebkitTapHighlightColor: 'transparent',
}}
data-tooltip={
isPlayingSegments
? 'Disabled during preview'
: 'Set start point at current position'
}
disabled={isPlayingSegments}
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 ${isPlayingSegments ? 'disabled' : ''}`}
style={{
userSelect: 'none',
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none',
touchAction: 'manipulation',
cursor: isPlayingSegments ? 'not-allowed' : 'pointer',
WebkitTapHighlightColor: 'transparent',
}}
data-tooltip={
isPlayingSegments
? 'Disabled during preview'
: 'Set end point at current position'
}
disabled={isPlayingSegments}
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),
}}
onClick={(e) => {
if (isPlayingSegments) {
e.stopPropagation();
e.preventDefault();
}
}}
>
{/* First row with time adjustment buttons - same as segment tooltip */}
<div className="tooltip-row">
<button
className={`tooltip-time-btn ${isPlayingSegments ? 'disabled' : ''}`}
data-tooltip={
isPlayingSegments ? 'Disabled during preview' : 'Seek -50ms (click or hold)'
}
disabled={isPlayingSegments}
{...(!isPlayingSegments ? handleContinuousTimeAdjustment(-0.05) : {})}
style={{
userSelect: 'none',
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none',
touchAction: 'manipulation',
cursor: isPlayingSegments ? 'not-allowed' : 'pointer',
WebkitTapHighlightColor: 'transparent',
}}
>
-50ms
</button>
<div
className={`tooltip-time-display ${isPlayingSegments ? 'disabled' : ''}`}
style={{
pointerEvents: isPlayingSegments ? 'none' : 'auto',
cursor: isPlayingSegments ? 'not-allowed' : 'default',
opacity: isPlayingSegments ? 0.6 : 1,
userSelect: 'none',
WebkitUserSelect: 'none',
}}
onClick={(e) => {
if (isPlayingSegments) {
e.stopPropagation();
e.preventDefault();
}
}}
>
{formatDetailedTime(clickedTime)}
</div>
<button
className={`tooltip-time-btn ${isPlayingSegments ? 'disabled' : ''}`}
style={{
userSelect: 'none',
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none',
touchAction: 'manipulation',
cursor: isPlayingSegments ? 'not-allowed' : 'pointer',
WebkitTapHighlightColor: 'transparent',
}}
data-tooltip={
isPlayingSegments ? 'Disabled during preview' : 'Seek +50ms (click or hold)'
}
disabled={isPlayingSegments}
{...(!isPlayingSegments ? 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 */}
<button
className={`tooltip-action-btn new-segment ${
availableSegmentDuration < 0.5 || isPlayingSegments ? 'disabled' : ''
}`}
style={{
userSelect: 'none',
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none',
touchAction: 'manipulation',
cursor: isPlayingSegments ? 'not-allowed' : 'pointer',
WebkitTapHighlightColor: 'transparent',
}}
data-tooltip={
isPlayingSegments
? 'Disabled during preview'
: availableSegmentDuration < 0.5
? 'Not enough space for new segment'
: 'Create new segment'
}
disabled={availableSegmentDuration < 0.5 || isPlayingSegments}
onClick={async (e) => {
e.stopPropagation();
// Only create if we have at least 0.5 seconds of space
if (availableSegmentDuration < 0.5) {
// Not enough space, do nothing
return;
}
// Create a new segment with the calculated available duration
const segmentStartTime = clickedTime;
const segmentEndTime = segmentStartTime + availableSegmentDuration;
// Create the new segment with proper chapter name
const newSegment: Segment = {
id: Date.now(),
chapterTitle: generateChapterName(segmentStartTime, clipSegments),
startTime: segmentStartTime,
endTime: segmentEndTime,
};
// 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 ${
isPlayingSegments ? 'disabled' : ''
}`}
data-tooltip={
isPlayingSegments ? 'Disabled during preview' : 'Play from beginning of cutaway'
}
style={{
userSelect: 'none',
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none',
touchAction: 'manipulation',
cursor: isPlayingSegments ? 'not-allowed' : 'pointer',
WebkitTapHighlightColor: 'transparent',
}}
disabled={isPlayingSegments}
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
chapterTitle: 'Cutaway',
startTime: startTime,
endTime: endTime,
};
// 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);
// No boundary checking - allow continuous playback
// 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 - Same as main play/pause button */}
<button
className={`tooltip-action-btn ${isPlaying ? 'pause' : 'play'} ${
isPlayingSegments ? 'disabled' : ''
}`}
data-tooltip={
isPlayingSegments
? 'Disabled during preview'
: isPlaying
? 'Pause playback'
: 'Play from here until next segment'
}
style={{
userSelect: 'none',
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none',
touchAction: 'manipulation',
cursor: isPlayingSegments ? 'not-allowed' : 'pointer',
WebkitTapHighlightColor: 'transparent',
}}
disabled={isPlayingSegments}
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 ${isPlayingSegments ? 'disabled' : ''}`}
style={{
userSelect: 'none',
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none',
touchAction: 'manipulation',
cursor: isPlayingSegments ? 'not-allowed' : 'pointer',
WebkitTapHighlightColor: 'transparent',
}}
data-tooltip={
isPlayingSegments ? 'Disabled during preview' : 'Adjust end of previous segment'
}
disabled={isPlayingSegments}
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(),
chapterTitle: generateChapterName(gapStart, clipSegments),
startTime: gapStart,
endTime: clickedTime,
};
// 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(),
chapterTitle: generateChapterName(0, clipSegments),
startTime: 0,
endTime: clickedTime,
};
// 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(),
chapterTitle: generateChapterName(0, clipSegments),
startTime: 0,
endTime: clickedTime,
};
// 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 ${
isPlayingSegments ? 'disabled' : ''
}`}
data-tooltip={
isPlayingSegments ? 'Disabled during preview' : 'Adjust start of next segment'
}
style={{
userSelect: 'none',
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none',
touchAction: 'manipulation',
cursor: isPlayingSegments ? 'not-allowed' : 'pointer',
WebkitTapHighlightColor: 'transparent',
}}
disabled={isPlayingSegments}
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(),
chapterTitle: generateChapterName(clickedTime, clipSegments),
startTime: clickedTime,
endTime: gapEnd,
};
// 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(),
chapterTitle: generateChapterName(clickedTime, clipSegments),
startTime: clickedTime,
endTime: sortedByStart[0].startTime,
};
// 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(),
chapterTitle: generateChapterName(clickedTime, clipSegments),
startTime: clickedTime,
endTime: duration,
};
// 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(),
chapterTitle: generateChapterName(clickedTime, clipSegments),
startTime: clickedTime,
endTime: duration,
};
// 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>
{/* Auto saved time */}
<div
className="auto-saved-time"
style={{
color: isAutoSaving ? '#1976d2' : 'gray',
fontSize: '12px',
marginLeft: '10px',
display: 'flex',
alignItems: 'center',
gap: '5px',
}}
>
{isAutoSaving ? (
<>
<span
className="auto-save-spinner"
style={{
display: 'inline-block',
width: '12px',
height: '12px',
border: '2px solid #f3f3f3',
borderTop: '2px solid #1976d2',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}}
></span>
Auto saving...
</>
) : lastAutoSaveTime ? (
`Auto saved: ${lastAutoSaveTime}`
) : (
'Not saved yet'
)}
</div>
{/* Save Chapters Button */}
<div className="save-buttons-row">
<button
onClick={() => setShowSaveChaptersModal(true)}
className="save-chapters-button"
data-tooltip={clipSegments.length === 0
? "Clear all chapters"
: "Save chapters"}
>
{clipSegments.length === 0
? 'Clear Chapters'
: 'Save Chapters'}
</button>
</div>
{/* Save Confirmation Modal */}
<Modal
isOpen={showSaveChaptersModal}
onClose={() => setShowSaveChaptersModal(false)}
title="Save Chapters"
actions={
<>
<button
className="modal-button modal-button-secondary"
onClick={() => setShowSaveChaptersModal(false)}
>
Cancel
</button>
<button
className="modal-button modal-button-primary"
onClick={handleSaveChaptersConfirm}
>
{clipSegments.length === 0
? 'Clear Chapters'
: 'Save Chapters'}
</button>
</>
}
>
<p className="modal-message">
{clipSegments.length === 0
? "Are you sure you want to clear all chapters? This will remove all existing chapters from the database."
: `Are you sure you want to save the chapters? This will save ${clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length} chapters to the database.`}
</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>
{/* 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">
<span style={{ fontWeight: 'bold' }}>Your chapters have been saved successfully!</span><br />
<a href={redirectUrl} className="media-page-link" style={mediaPageLinkStyles}>
Click here to navigate to the media page
</a>
{' '}or close this window to continue editing the chapters.
</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;