Yiannis Christodoulou 2025-05-21 06:16:35 +03:00
parent 0c75e2503c
commit 347ce9af6f
12 changed files with 798 additions and 504 deletions

View File

@ -5,3 +5,4 @@ server/public
vite.config.ts.* vite.config.ts.*
*.tar.gz *.tar.gz
yt.readme.md yt.readme.md
client/public/videos/sample-video.mp4

View File

@ -1,4 +1,6 @@
import { useRef, useEffect, useState } from "react"; import { useRef, useEffect, useState } from "react";
import { formatTime, formatDetailedTime } from "./lib/timeUtils";
import logger from "./lib/logger";
import VideoPlayer from "@/components/VideoPlayer"; import VideoPlayer from "@/components/VideoPlayer";
import TimelineControls from "@/components/TimelineControls"; import TimelineControls from "@/components/TimelineControls";
import EditingTools from "@/components/EditingTools"; import EditingTools from "@/components/EditingTools";
@ -7,85 +9,48 @@ import MobilePlayPrompt from "@/components/IOSPlayPrompt";
import useVideoTrimmer from "@/hooks/useVideoTrimmer"; import useVideoTrimmer from "@/hooks/useVideoTrimmer";
const App = () => { const App = () => {
const [isMobile, setIsMobile] = useState(false);
const [videoInitialized, setVideoInitialized] = useState(false);
const { const {
videoRef, videoRef,
currentTime, currentTime,
duration, duration,
isPlaying, isPlaying,
isPreviewMode, setIsPlaying,
isMuted, isMuted,
isPreviewMode,
thumbnails, thumbnails,
trimStart, trimStart,
trimEnd, trimEnd,
splitPoints, splitPoints,
zoomLevel, zoomLevel,
clipSegments, clipSegments,
hasUnsavedChanges,
historyPosition, historyPosition,
history, history,
hasUnsavedChanges,
playPauseVideo,
seekVideo,
handleTrimStartChange, handleTrimStartChange,
handleTrimEndChange, handleTrimEndChange,
handleZoomChange,
handleMobileSafeSeek,
handleSplit, handleSplit,
handleReset, handleReset,
handleUndo, handleUndo,
handleRedo, handleRedo,
handlePreview, handlePreview,
handlePlay,
handleZoomChange,
toggleMute, toggleMute,
handleSave, handleSave,
handleSaveACopy, handleSaveACopy,
handleSaveSegments, handleSaveSegments,
isMobile,
videoInitialized,
setVideoInitialized,
} = useVideoTrimmer(); } = useVideoTrimmer();
// Refs for hold-to-continue functionality
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
const decrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Detect if we're on a mobile device and reset on each visit
useEffect(() => {
const checkIsMobile = () => {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(navigator.userAgent);
};
setIsMobile(checkIsMobile());
setVideoInitialized(false); // Reset each time for mobile devices
// Add an event listener to detect when the video has been played
const video = videoRef.current;
if (video) {
const handlePlay = () => {
setVideoInitialized(true);
};
video.addEventListener('play', handlePlay);
return () => {
video.removeEventListener('play', handlePlay);
};
}
}, [videoRef]);
// Clean up intervals on unmount
useEffect(() => {
return () => {
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
};
}, []);
// Function to play from the beginning // Function to play from the beginning
const playFromBeginning = () => { const playFromBeginning = () => {
if (videoRef.current) { if (videoRef.current) {
videoRef.current.currentTime = 0; videoRef.current.currentTime = 0;
seekVideo(0); handleMobileSafeSeek(0);
if (!isPlaying) { if (!isPlaying) {
playPauseVideo(); handlePlay();
} }
} }
}; };
@ -93,80 +58,176 @@ const App = () => {
// Function to jump 15 seconds backward // Function to jump 15 seconds backward
const jumpBackward15 = () => { const jumpBackward15 = () => {
const newTime = Math.max(0, currentTime - 15); const newTime = Math.max(0, currentTime - 15);
seekVideo(newTime); handleMobileSafeSeek(newTime);
}; };
// Function to jump 15 seconds forward // Function to jump 15 seconds forward
const jumpForward15 = () => { const jumpForward15 = () => {
const newTime = Math.min(duration, currentTime + 15); const newTime = Math.min(duration, currentTime + 15);
seekVideo(newTime); handleMobileSafeSeek(newTime);
}; };
// Start continuous 50ms increment when button is held const handlePlay = () => {
const startIncrement = (e: React.MouseEvent | React.TouchEvent) => { if (!videoRef.current) return;
// Prevent default to avoid text selection
e.preventDefault();
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current); const video = videoRef.current;
// First immediate adjustment // If already playing, just pause the video
seekVideo(Math.min(duration, currentTime + 0.05)); if (isPlaying) {
video.pause();
setIsPlaying(false);
return;
}
// Setup continuous adjustment const currentPosition = Number(video.currentTime.toFixed(6)); // Fix to microsecond precision
incrementIntervalRef.current = setInterval(() => {
const currentVideoTime = videoRef.current?.currentTime || 0;
const newTime = Math.min(duration, currentVideoTime + 0.05);
seekVideo(newTime);
}, 100);
};
// Stop continuous increment // Find the next stopping point based on current position
const stopIncrement = () => { let stopTime = duration;
if (incrementIntervalRef.current) { let currentSegment = null;
clearInterval(incrementIntervalRef.current); let nextSegment = null;
incrementIntervalRef.current = null;
// Sort segments by start time to ensure correct order
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
// First, check if we're inside a segment or exactly at its start/end
currentSegment = sortedSegments.find(seg => {
const segStartTime = Number(seg.startTime.toFixed(6));
const segEndTime = Number(seg.endTime.toFixed(6));
// Check if we're inside the segment
if (currentPosition > segStartTime && currentPosition < segEndTime) {
return true;
}
// Check if we're exactly at the start
if (currentPosition === segStartTime) {
return true;
}
// Check if we're exactly at the end
if (currentPosition === segEndTime) {
// If we're at the end of a segment, we should look for the next one
return false;
}
return false;
});
// If we're not in a segment, find the next segment
if (!currentSegment) {
nextSegment = sortedSegments.find(seg => {
const segStartTime = Number(seg.startTime.toFixed(6));
return segStartTime > currentPosition;
});
}
// Determine where to stop based on position
if (currentSegment) {
// If we're in a segment, stop at its end
stopTime = Number(currentSegment.endTime.toFixed(6));
} else if (nextSegment) {
// If we're in a cutaway and there's a next segment, stop at its start
stopTime = Number(nextSegment.startTime.toFixed(6));
}
// Create a boundary checker function with high precision
const checkBoundary = () => {
if (!video) return;
const currentPosition = Number(video.currentTime.toFixed(6));
const timeLeft = Number((stopTime - currentPosition).toFixed(6));
// If we've reached or passed the boundary
if (timeLeft <= 0 || currentPosition >= stopTime) {
// First pause playback
video.pause();
// Force exact position with multiple verification attempts
const setExactPosition = () => {
if (!video) return;
// Set to exact boundary time
video.currentTime = stopTime;
handleMobileSafeSeek(stopTime);
const actualPosition = Number(video.currentTime.toFixed(6));
const difference = Number(Math.abs(actualPosition - stopTime).toFixed(6));
logger.debug("Position verification:", {
target: formatDetailedTime(stopTime),
actual: formatDetailedTime(actualPosition),
difference: difference
});
// If we're not exactly at the target position, try one more time
if (difference > 0) {
video.currentTime = stopTime;
handleMobileSafeSeek(stopTime);
} }
}; };
// Start continuous 50ms decrement when button is held // Multiple attempts to ensure precision, with increasing delays
const startDecrement = (e: React.MouseEvent | React.TouchEvent) => { setExactPosition();
// Prevent default to avoid text selection setTimeout(setExactPosition, 5); // Quick first retry
e.preventDefault(); setTimeout(setExactPosition, 10); // Second retry
setTimeout(setExactPosition, 20); // Third retry if needed
setTimeout(setExactPosition, 50); // Final verification
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current); // Remove our boundary checker
video.removeEventListener('timeupdate', checkBoundary);
setIsPlaying(false);
// First immediate adjustment // Log the final position for debugging
seekVideo(Math.max(0, currentTime - 0.05)); logger.debug("Stopped at position:", {
target: formatDetailedTime(stopTime),
actual: formatDetailedTime(video.currentTime),
type: currentSegment ? "segment end" : (nextSegment ? "next segment start" : "end of video"),
segment: currentSegment ? {
id: currentSegment.id,
start: formatDetailedTime(currentSegment.startTime),
end: formatDetailedTime(currentSegment.endTime)
} : null,
nextSegment: nextSegment ? {
id: nextSegment.id,
start: formatDetailedTime(nextSegment.startTime),
end: formatDetailedTime(nextSegment.endTime)
} : null
});
// Setup continuous adjustment return;
decrementIntervalRef.current = setInterval(() => {
const currentVideoTime = videoRef.current?.currentTime || 0;
const newTime = Math.max(0, currentVideoTime - 0.05);
seekVideo(newTime);
}, 100);
};
// Stop continuous decrement
const stopDecrement = () => {
if (decrementIntervalRef.current) {
clearInterval(decrementIntervalRef.current);
decrementIntervalRef.current = null;
} }
}; };
// Handle seeking with mobile check // Start our boundary checker
const handleMobileSafeSeek = (time: number) => { video.addEventListener('timeupdate', checkBoundary);
// Only allow seeking if not on mobile or if video has been played
if (!isMobile || videoInitialized) { // Start playing
seekVideo(time); video.play()
} .then(() => {
setIsPlaying(true);
setVideoInitialized(true);
logger.debug("Playback started:", {
from: formatDetailedTime(currentPosition),
to: formatDetailedTime(stopTime),
currentSegment: currentSegment ? {
id: currentSegment.id,
start: formatDetailedTime(currentSegment.startTime),
end: formatDetailedTime(currentSegment.endTime)
} : 'None',
nextSegment: nextSegment ? {
id: nextSegment.id,
start: formatDetailedTime(nextSegment.startTime),
end: formatDetailedTime(nextSegment.endTime)
} : 'None'
});
})
.catch(err => {
console.error("Error playing video:", err);
});
}; };
return ( return (
<div className="bg-background min-h-screen"> <div className="bg-background min-h-screen">
<MobilePlayPrompt <MobilePlayPrompt
videoRef={videoRef} videoRef={videoRef}
onPlay={playPauseVideo} onPlay={handlePlay}
/> />
<div className="container mx-auto px-4 py-6 max-w-6xl"> <div className="container mx-auto px-4 py-6 max-w-6xl">
@ -177,7 +238,7 @@ const App = () => {
duration={duration} duration={duration}
isPlaying={isPlaying} isPlaying={isPlaying}
isMuted={isMuted} isMuted={isMuted}
onPlayPause={playPauseVideo} onPlayPause={handlePlay}
onSeek={handleMobileSafeSeek} onSeek={handleMobileSafeSeek}
onToggleMute={toggleMute} onToggleMute={toggleMute}
/> />
@ -217,6 +278,9 @@ const App = () => {
isPreviewMode={isPreviewMode} isPreviewMode={isPreviewMode}
hasUnsavedChanges={hasUnsavedChanges} hasUnsavedChanges={hasUnsavedChanges}
isIOSUninitialized={isMobile && !videoInitialized} isIOSUninitialized={isMobile && !videoInitialized}
isPlaying={isPlaying}
setIsPlaying={setIsPlaying}
onPlayPause={handlePlay}
/> />
{/* Clip Segments */} {/* Clip Segments */}

View File

@ -47,7 +47,7 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
return ( return (
<div className="mobile-play-prompt-overlay"> <div className="mobile-play-prompt-overlay">
<div className="mobile-play-prompt"> <div className="mobile-play-prompt">
<h3>Mobile Device Notice</h3> {/* <h3>Mobile Device Notice</h3>
<p> <p>
For the best video editing experience on mobile devices, you need to <strong>play the video first</strong> before For the best video editing experience on mobile devices, you need to <strong>play the video first</strong> before
@ -61,13 +61,13 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
<li>After the video starts, you can pause it</li> <li>After the video starts, you can pause it</li>
<li>Then you'll be able to use all timeline controls</li> <li>Then you'll be able to use all timeline controls</li>
</ol> </ol>
</div> </div> */}
<button <button
className="mobile-play-button" className="mobile-play-button"
onClick={handlePlayClick} onClick={handlePlayClick}
> >
Play Video Now Click to start editing...
</button> </button>
</div> </div>
</div> </div>

View File

@ -37,7 +37,7 @@ const IOSVideoPlayer = ({
} }
} else { } else {
// Fallback to sample video if needed // Fallback to sample video if needed
setVideoUrl("https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"); setVideoUrl("/videos/sample-video-30s.mp4");
} }
}, [videoRef]); }, [videoRef]);

View File

@ -34,6 +34,9 @@ interface TimelineControlsProps {
isPreviewMode?: boolean; isPreviewMode?: boolean;
hasUnsavedChanges?: boolean; hasUnsavedChanges?: boolean;
isIOSUninitialized?: boolean; isIOSUninitialized?: boolean;
isPlaying: boolean;
setIsPlaying: (playing: boolean) => void;
onPlayPause: () => void; // Add this prop
} }
// Function to calculate and constrain tooltip position to keep it on screen // Function to calculate and constrain tooltip position to keep it on screen
@ -77,7 +80,10 @@ const TimelineControls = ({
onSaveSegments, onSaveSegments,
isPreviewMode, isPreviewMode,
hasUnsavedChanges = false, hasUnsavedChanges = false,
isIOSUninitialized = false isIOSUninitialized = false,
isPlaying,
setIsPlaying,
onPlayPause // Add this prop
}: TimelineControlsProps) => { }: TimelineControlsProps) => {
const timelineRef = useRef<HTMLDivElement>(null); const timelineRef = useRef<HTMLDivElement>(null);
const leftHandleRef = useRef<HTMLDivElement>(null); const leftHandleRef = useRef<HTMLDivElement>(null);
@ -162,6 +168,40 @@ const TimelineControls = ({
setClickedTime(newTime); setClickedTime(newTime);
setDisplayTime(newTime); setDisplayTime(newTime);
// Update tooltip position and type during drag
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
});
// Create a temporary segment with the current drag position
const draggedSegment = {
...segment,
startTime: isLeft ? newTime : segment.startTime,
endTime: isLeft ? segment.endTime : newTime
};
// Check if the current marker position (currentTime) is within the dragged segment
const isMarkerInSegment = currentTime >= draggedSegment.startTime && currentTime <= draggedSegment.endTime;
if (isMarkerInSegment) {
// Show segment tooltip if marker is inside the segment
setSelectedSegmentId(segment.id);
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);
}
}
// Resume playback if it was playing before // Resume playback if it was playing before
if (wasPlaying && videoRef.current) { if (wasPlaying && videoRef.current) {
videoRef.current.play(); videoRef.current.play();
@ -786,34 +826,11 @@ const TimelineControls = ({
// Global click handler to close tooltips when clicking outside // Global click handler to close tooltips when clicking outside
useEffect(() => { useEffect(() => {
const handleGlobalClick = (event: MouseEvent) => { // Remove the global click handler that closes tooltips
const target = event.target as HTMLElement; // This keeps the popup always visible, even when clicking outside the timeline
// Close tooltips when clicking outside of tooltips and timeline marker head // Keeping the dependency array to avoid linting errors
if ((selectedSegmentId !== null || showEmptySpaceTooltip) && return () => {};
!target.closest('.empty-space-tooltip') &&
!target.closest('.segment-tooltip') &&
!target.closest('.timeline-marker-head') &&
!target.closest('.clip-segment') &&
!target.closest('.timeline-container')) {
// Reset play state when closing tooltip
if (isPlayingSegment) {
setIsPlayingSegment(false);
setActiveSegment(null);
}
// Also reset continuePastBoundary flag when closing tooltips
setContinuePastBoundary(false);
logger.debug("Closing tooltip - resetting continuePastBoundary flag");
setSelectedSegmentId(null);
setShowEmptySpaceTooltip(false);
}
};
document.addEventListener('mousedown', handleGlobalClick);
return () => {
document.removeEventListener('mousedown', handleGlobalClick);
};
}, [selectedSegmentId, showEmptySpaceTooltip, isPlayingSegment]); }, [selectedSegmentId, showEmptySpaceTooltip, isPlayingSegment]);
// Initialize drag handlers for trim handles // Initialize drag handlers for trim handles
@ -969,32 +986,49 @@ const TimelineControls = ({
if (duration - startTime < 0.3) { if (duration - startTime < 0.3) {
logger.debug("Very close to end of video, ensuring tooltip can show:", logger.debug("Very close to end of video, ensuring tooltip can show:",
formatDetailedTime(startTime), "video end:", formatDetailedTime(duration)); formatDetailedTime(startTime), "video end:", formatDetailedTime(duration));
return 0.5; // Minimum value to show tooltip return Math.max(0.5, remainingDuration); // Use actual remaining duration if larger
} }
// 2. Find the next segment (if any) // 2. Find the next segment (if any)
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
// Check if we're exactly at a segment boundary (start or end of any segment) // Check if we're in a cutaway area near a segment boundary
// Use a small tolerance for floating point comparison // Use a larger tolerance (100ms) for boundary detection
const isAtSegmentBoundary = sortedSegments.some(seg => const isNearSegmentBoundary = sortedSegments.some(seg => {
Math.abs(startTime - seg.startTime) < 0.01 || const distanceToStart = Math.abs(startTime - seg.startTime);
Math.abs(startTime - seg.endTime) < 0.01 const distanceToEnd = Math.abs(startTime - seg.endTime);
); // Consider both start and end boundaries with different tolerances
return (distanceToStart < 0.1 && distanceToStart > 0) || // Near start but not exactly at it
(distanceToEnd < 0.1 && distanceToEnd > 0); // Near end but not exactly at it
});
// If we're exactly at a segment boundary, return a small non-zero value to ensure tooltip shows // If we're near a segment boundary in cutaway area, ensure tooltip shows
if (isAtSegmentBoundary) { if (isNearSegmentBoundary) {
logger.debug("Near segment boundary in cutaway area:", formatDetailedTime(startTime));
return 0.5; // Minimum value to show tooltip return 0.5; // Minimum value to show tooltip
} }
const nextSegment = sortedSegments.find(seg => seg.startTime > startTime); const nextSegment = sortedSegments.find(seg => seg.startTime > startTime);
const prevSegment = [...sortedSegments].reverse().find(seg => seg.endTime < startTime);
if (nextSegment) { if (nextSegment) {
// Space available until the next segment starts // Space available until the next segment starts
const spaceUntilNextSegment = Math.max(0, nextSegment.startTime - startTime); const spaceUntilNextSegment = Math.max(0, nextSegment.startTime - startTime);
// If we're very close to the next segment, ensure tooltip still shows
if (spaceUntilNextSegment < 0.1) {
return 0.5;
}
return Math.min(30, spaceUntilNextSegment); // Take either 30s or available space, whichever is smaller return Math.min(30, spaceUntilNextSegment); // Take either 30s or available space, whichever is smaller
} else if (prevSegment) {
// We're after the last segment, use remaining duration
const spaceAfterPrevSegment = Math.max(0, duration - startTime);
// If we're very close to the previous segment's end, ensure tooltip shows
if (startTime - prevSegment.endTime < 0.1) {
return 0.5;
}
return Math.min(30, spaceAfterPrevSegment);
} else { } else {
// No next segment, just limited by video duration // No segments at all, use remaining duration
return Math.min(30, remainingDuration); return Math.min(30, remainingDuration);
} }
}; };
@ -1048,9 +1082,6 @@ const TimelineControls = ({
setClickedTime(newTime); setClickedTime(newTime);
setDisplayTime(newTime); setDisplayTime(newTime);
// Special case: when clicking very close to the end of the video
const isNearVideoEnd = duration - newTime < 0.3; // Within 300ms of the end
// Find if we clicked in a segment with a small tolerance for boundaries // Find if we clicked in a segment with a small tolerance for boundaries
const segmentAtClickedTime = clipSegments.find(seg => { const segmentAtClickedTime = clipSegments.find(seg => {
// Standard check for being inside a segment // Standard check for being inside a segment
@ -1085,95 +1116,18 @@ const TimelineControls = ({
// Only process tooltip display if clicked on the timeline background or thumbnails, not on other UI elements // Only process tooltip display if clicked on the timeline background or thumbnails, not on other UI elements
if (e.target === timelineRef.current || (e.target as HTMLElement).classList.contains('timeline-thumbnail')) { if (e.target === timelineRef.current || (e.target as HTMLElement).classList.contains('timeline-thumbnail')) {
// Special handling for near-end-of-video clicks
if (isNearVideoEnd) {
logger.debug("Near end of video - showing empty space tooltip");
// Force show the empty space tooltip
setSelectedSegmentId(null);
setShowEmptySpaceTooltip(true);
setAvailableSegmentDuration(0.5); // Minimum value
// Calculate and set tooltip position
let xPos;
if (zoomLevel > 1) {
const visibleTimelineLeft = rect.left - scrollContainerRef.current.scrollLeft;
const clickPosPercent = newTime / duration;
xPos = visibleTimelineLeft + (clickPosPercent * rect.width);
} else {
xPos = e.clientX;
}
setTooltipPosition({
x: xPos,
y: rect.top - 10
});
return; // Exit early since we've handled this special case
}
// First, check if we're at a segment boundary with a small tolerance
const isAtSegmentBoundary = clipSegments.some(seg =>
Math.abs(newTime - seg.startTime) < 0.01 ||
Math.abs(newTime - seg.endTime) < 0.01
);
// If we're at a segment boundary, ensure we can still show a tooltip
if (isAtSegmentBoundary) {
logger.debug("Clicked exactly at segment boundary:", formatDetailedTime(newTime));
// Find the segment whose boundary we clicked on
const boundarySegment = clipSegments.find(seg =>
Math.abs(newTime - seg.startTime) < 0.01 ||
Math.abs(newTime - seg.endTime) < 0.01
);
if (boundarySegment) {
// If we clicked at the exact end of a segment, show that segment's tooltip
if (Math.abs(newTime - boundarySegment.endTime) < 0.01) {
setSelectedSegmentId(boundarySegment.id);
setShowEmptySpaceTooltip(false);
// Calculate and set tooltip position
let xPos;
if (zoomLevel > 1) {
const visibleTimelineLeft = rect.left - scrollContainerRef.current.scrollLeft;
const clickPosPercent = newTime / duration;
xPos = visibleTimelineLeft + (clickPosPercent * rect.width);
} else {
xPos = e.clientX;
}
setTooltipPosition({
x: xPos,
y: rect.top - 10
});
return; // Exit early since we've handled this case
}
}
// For other boundary cases, continue to normal processing
}
// Check if there's a segment at the clicked position // Check if there's a segment at the clicked position
if (segmentAtClickedTime) { if (segmentAtClickedTime) {
setSelectedSegmentId(segmentAtClickedTime.id); setSelectedSegmentId(segmentAtClickedTime.id);
setShowEmptySpaceTooltip(false); setShowEmptySpaceTooltip(false);
} else { } else {
// First, close segment tooltip if open // We're in a cutaway area - always show tooltip
setSelectedSegmentId(null); setSelectedSegmentId(null);
// Calculate the available space for a new segment // Calculate the available space for a new segment
const availableSpace = calculateAvailableSpace(newTime); const availableSpace = calculateAvailableSpace(newTime);
setAvailableSegmentDuration(availableSpace); setAvailableSegmentDuration(availableSpace);
// If there's no space to create even a minimal segment (at least 0.5 seconds), don't show the tooltip
if (availableSpace < 0.5) {
setShowEmptySpaceTooltip(false);
return;
}
// Calculate and set tooltip position correctly for zoomed timeline // Calculate and set tooltip position correctly for zoomed timeline
let xPos; let xPos;
if (zoomLevel > 1) { if (zoomLevel > 1) {
@ -1191,18 +1145,20 @@ const TimelineControls = ({
y: rect.top - 10 // Position tooltip above the timeline y: rect.top - 10 // Position tooltip above the timeline
}); });
// Show the empty space tooltip // Always show the empty space tooltip in cutaway areas
setShowEmptySpaceTooltip(true); setShowEmptySpaceTooltip(true);
// Close tooltip when clicking outside // Log the cutaway area details
const handleClickOutside = (event: MouseEvent) => { const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
const target = event.target as HTMLElement; const prevSegment = [...sortedSegments].reverse().find(seg => seg.endTime < newTime);
const nextSegment = sortedSegments.find(seg => seg.startTime > newTime);
// This is now handled by the global document click handler - just remove this listener logger.debug("Clicked in cutaway area:", {
document.removeEventListener('mousedown', handleClickOutside); position: formatDetailedTime(newTime),
}; availableSpace: formatDetailedTime(availableSpace),
prevSegmentEnd: prevSegment ? formatDetailedTime(prevSegment.endTime) : "none",
document.addEventListener('mousedown', handleClickOutside); nextSegmentStart: nextSegment ? formatDetailedTime(nextSegment.startTime) : "none"
});
} }
} }
}; };
@ -1251,10 +1207,7 @@ const TimelineControls = ({
detail: { segmentId } detail: { segmentId }
})); }));
// Hide tooltip during drag // Keep the tooltip visible during drag
setSelectedSegmentId(null);
setShowEmptySpaceTooltip(false);
// Function to handle both mouse and touch movements // Function to handle both mouse and touch movements
const handleDragMove = (clientX: number) => { const handleDragMove = (clientX: number) => {
if (!isDragging || !timelineRef.current) return; if (!isDragging || !timelineRef.current) return;
@ -1263,6 +1216,34 @@ const TimelineControls = ({
const position = Math.max(0, Math.min(1, (clientX - updatedTimelineRect.left) / updatedTimelineRect.width)); const position = Math.max(0, Math.min(1, (clientX - updatedTimelineRect.left) / updatedTimelineRect.width));
const newTime = position * duration; const newTime = position * duration;
// Create a temporary segment with the current drag position to check against
const draggedSegment = {
id: segmentId,
startTime: isLeft ? newTime : originalStartTime,
endTime: isLeft ? originalEndTime : newTime,
name: '',
thumbnail: ''
};
// Check if the current marker position intersects with where the segment will be
const currentSegmentStart = isLeft ? newTime : originalStartTime;
const currentSegmentEnd = isLeft ? originalEndTime : newTime;
const isMarkerInSegment = currentTime >= currentSegmentStart && currentTime <= currentSegmentEnd;
// Update tooltip based on marker intersection
if (isMarkerInSegment) {
// Show segment tooltip if marker is inside the segment
setSelectedSegmentId(segmentId);
setShowEmptySpaceTooltip(false);
} else {
// Show cutaway tooltip if marker is outside the segment
setSelectedSegmentId(null);
// Calculate available space for cutaway tooltip
const availableSpace = calculateAvailableSpace(currentTime);
setAvailableSegmentDuration(availableSpace);
setShowEmptySpaceTooltip(true);
}
// Find neighboring segments (exclude the current one) // Find neighboring segments (exclude the current one)
const otherSegments = clipSegments.filter(seg => seg.id !== segmentId); const otherSegments = clipSegments.filter(seg => seg.id !== segmentId);
@ -1400,10 +1381,6 @@ const TimelineControls = ({
document.body.removeChild(overlay); document.body.removeChild(overlay);
} }
// Keep tooltip hidden after drag
setSelectedSegmentId(null);
setShowEmptySpaceTooltip(false);
// Record the final position in history as a single action // Record the final position in history as a single action
const finalSegments = clipSegments.map(seg => { const finalSegments = clipSegments.map(seg => {
if (seg.id === segmentId) { if (seg.id === segmentId) {
@ -1739,43 +1716,42 @@ const TimelineControls = ({
// Add a new useEffect hook to listen for segment deletion events // Add a new useEffect hook to listen for segment deletion events
useEffect(() => { useEffect(() => {
// Handle the segment deletion event
const handleSegmentDelete = (event: CustomEvent) => { const handleSegmentDelete = (event: CustomEvent) => {
const { segmentId } = event.detail; const { segmentId } = event.detail;
// If the deleted segment is the one with the currently open tooltip // Check if this was the last segment before deletion
if (selectedSegmentId === segmentId) { const remainingSegments = clipSegments.filter(seg => seg.id !== segmentId);
const deletedSegmentIndex = clipSegments.findIndex(seg => seg.id === segmentId); if (remainingSegments.length === 0) {
if (deletedSegmentIndex !== -1) { // Create a full video segment
const deletedSegment = clipSegments[deletedSegmentIndex]; const fullVideoSegment: Segment = {
id: Date.now(),
name: 'Full Video',
startTime: 0,
endTime: duration,
thumbnail: ''
};
// We need the current time to check if we should show the cutaway tooltip // Create and dispatch the update event to replace all segments with the full video segment
const currentVideoTime = currentTime; const updateEvent = new CustomEvent('update-segments', {
detail: {
segments: [fullVideoSegment],
recordHistory: true,
action: 'create_full_video_segment'
}
});
document.dispatchEvent(updateEvent);
// Check if the current time was within the deleted segment // Update UI to show the segment tooltip
const wasInsideDeletedSegment = setSelectedSegmentId(fullVideoSegment.id);
currentVideoTime >= deletedSegment.startTime && setShowEmptySpaceTooltip(false);
currentVideoTime <= deletedSegment.endTime; setClickedTime(currentTime);
setDisplayTime(currentTime);
setActiveSegment(fullVideoSegment);
// Calculate position in the middle of the deleted segment for tooltip // Calculate tooltip position at current time
const deletedSegmentMiddle = (deletedSegment.startTime + deletedSegment.endTime) / 2;
const timeToUse = wasInsideDeletedSegment ? currentVideoTime : deletedSegmentMiddle;
// Calculate available space after deletion
const availableSpace = calculateAvailableSpace(timeToUse);
// Update UI to show cutaway tooltip in place of segment tooltip
setSelectedSegmentId(null);
if (availableSpace >= 0.5) {
// Set the time for the tooltip
setClickedTime(timeToUse);
setDisplayTime(timeToUse);
// Calculate tooltip position
if (timelineRef.current) { if (timelineRef.current) {
const rect = timelineRef.current.getBoundingClientRect(); const rect = timelineRef.current.getBoundingClientRect();
const posPercent = (timeToUse / duration) * 100; const posPercent = (currentTime / duration) * 100;
const xPosition = rect.left + (rect.width * (posPercent / 100)); const xPosition = rect.left + (rect.width * (posPercent / 100));
setTooltipPosition({ setTooltipPosition({
@ -1783,20 +1759,40 @@ const TimelineControls = ({
y: rect.top - 10 y: rect.top - 10
}); });
// Show the empty space tooltip logger.debug("Created full video segment:", {
setAvailableSegmentDuration(availableSpace); id: fullVideoSegment.id,
setShowEmptySpaceTooltip(true); duration: formatDetailedTime(duration),
currentPosition: formatDetailedTime(currentTime)
});
}
} else if (selectedSegmentId === segmentId) {
// Handle normal segment deletion
const deletedSegment = clipSegments.find(seg => seg.id === segmentId);
if (!deletedSegment) return;
logger.debug("Segment deleted, showing cutaway tooltip with available space:", // Calculate available space after deletion
formatDetailedTime(availableSpace), const availableSpace = calculateAvailableSpace(currentTime);
"at position:",
formatDetailedTime(timeToUse) // Update UI to show cutaway tooltip
); setSelectedSegmentId(null);
} setShowEmptySpaceTooltip(true);
} else { setAvailableSegmentDuration(availableSpace);
// Not enough space for a new segment, hide tooltips
setShowEmptySpaceTooltip(false); // 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)
});
} }
} }
}; };
@ -1808,7 +1804,7 @@ const TimelineControls = ({
return () => { return () => {
document.removeEventListener('delete-segment', handleSegmentDelete as EventListener); document.removeEventListener('delete-segment', handleSegmentDelete as EventListener);
}; };
}, [selectedSegmentId, clipSegments, currentTime, duration]); }, [selectedSegmentId, clipSegments, currentTime, duration, timelineRef]);
// Add an effect to synchronize tooltip play state with video play state // Add an effect to synchronize tooltip play state with video play state
useEffect(() => { useEffect(() => {
@ -1816,8 +1812,124 @@ const TimelineControls = ({
if (!video) return; if (!video) return;
const handlePlay = () => { const handlePlay = () => {
logger.debug("Video started playing from external control"); if (!videoRef.current) return;
const video = videoRef.current;
const currentPosition = video.currentTime;
// Reset continuePastBoundary flag when starting new playback
setContinuePastBoundary(false);
// Find the next stopping point based on current position
let stopTime = duration;
let currentSegment = null;
let nextSegment = null;
// First, check if we're inside a segment with high precision
currentSegment = clipSegments.find(seg => {
const isWithinSegment = currentPosition >= seg.startTime && currentPosition <= seg.endTime;
const isAtExactStart = Math.abs(currentPosition - seg.startTime) < 0.001; // Within 1ms of start
const isAtExactEnd = Math.abs(currentPosition - seg.endTime) < 0.001; // Within 1ms of end
return isWithinSegment || isAtExactStart || isAtExactEnd;
});
// Find the next segment with high precision
nextSegment = clipSegments
.filter(seg => {
const isAfterCurrent = seg.startTime > currentPosition;
const isNotAtExactPosition = Math.abs(seg.startTime - currentPosition) > 0.001;
return isAfterCurrent && isNotAtExactPosition;
})
.sort((a, b) => a.startTime - b.startTime)[0];
// Determine where to stop based on position
if (currentSegment) {
// If we're in a segment, stop at its end
stopTime = currentSegment.endTime;
setActiveSegment(currentSegment);
} else if (nextSegment) {
// If we're in a cutaway and there's a next segment, stop at its start
stopTime = nextSegment.startTime;
// Don't set active segment since we're in a cutaway
}
// Create a boundary checker function with high precision
const checkBoundary = () => {
if (!video) return;
const currentPosition = video.currentTime;
const timeLeft = stopTime - currentPosition;
// If we're approaching the boundary (within 1ms) or have passed it
if (timeLeft <= 0.001 || currentPosition >= stopTime) {
// First pause playback
video.pause();
// Force exact position with multiple verification attempts
const setExactPosition = () => {
if (!video) return;
// Set to exact boundary time
video.currentTime = stopTime;
onSeek(stopTime);
setDisplayTime(stopTime);
setClickedTime(stopTime);
logger.debug("Position verification:", {
target: formatDetailedTime(stopTime),
actual: formatDetailedTime(video.currentTime),
difference: Math.abs(video.currentTime - stopTime).toFixed(3)
});
};
// Multiple attempts to ensure precision
setExactPosition();
setTimeout(setExactPosition, 10);
setTimeout(setExactPosition, 20);
setTimeout(setExactPosition, 50);
// Update UI based on where we stopped
if (currentSegment) {
setSelectedSegmentId(currentSegment.id);
setShowEmptySpaceTooltip(false);
} else if (nextSegment) {
setSelectedSegmentId(nextSegment.id);
setShowEmptySpaceTooltip(false);
setActiveSegment(nextSegment);
} else {
setSelectedSegmentId(null);
setShowEmptySpaceTooltip(true);
setActiveSegment(null);
}
// Remove our boundary checker
video.removeEventListener('timeupdate', checkBoundary);
setIsPlaying(false);
setIsPlayingSegment(false);
// Reset continuePastBoundary flag when stopping at boundary
setContinuePastBoundary(false);
return;
}
};
// Start our boundary checker
video.addEventListener('timeupdate', checkBoundary);
// Start playing
video.play()
.then(() => {
setIsPlaying(true);
setIsPlayingSegment(true); setIsPlayingSegment(true);
logger.debug("Playback started:", {
from: formatDetailedTime(currentPosition),
to: formatDetailedTime(stopTime),
currentSegment: currentSegment ? `Segment ${currentSegment.id}` : 'None',
nextSegment: nextSegment ? `Segment ${nextSegment.id}` : 'None'
});
})
.catch(err => {
console.error("Error playing video:", err);
});
}; };
const handlePause = () => { const handlePause = () => {
@ -1832,7 +1944,7 @@ const TimelineControls = ({
video.removeEventListener('play', handlePlay); video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause); video.removeEventListener('pause', handlePause);
}; };
}, []); }, [clipSegments, duration, onSeek]);
// Handle mouse movement over timeline to remember position // Handle mouse movement over timeline to remember position
const handleTimelineMouseMove = (e: React.MouseEvent<HTMLDivElement>) => { const handleTimelineMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
@ -2248,8 +2360,9 @@ const TimelineControls = ({
className="timeline-marker" className="timeline-marker"
style={{ left: `${currentTimePercent}%` }} style={{ left: `${currentTimePercent}%` }}
> >
{/* Top circle for popup toggle */}
<div <div
className={`timeline-marker-head ${isDragging ? 'dragging' : ''}`} className="timeline-marker-head"
onClick={(e) => { onClick={(e) => {
// Prevent event propagation to avoid triggering the timeline container click // Prevent event propagation to avoid triggering the timeline container click
e.stopPropagation(); e.stopPropagation();
@ -2300,13 +2413,20 @@ const TimelineControls = ({
} }
} }
}} }}
onMouseDown={startDrag}
onTouchStart={startTouchDrag}
> >
<span className="timeline-marker-head-icon"> <span className="timeline-marker-head-icon">
{selectedSegmentId || showEmptySpaceTooltip ? '-' : '+'} {selectedSegmentId || showEmptySpaceTooltip ? '-' : '+'}
</span> </span>
</div> </div>
{/* Bottom circle for dragging */}
<div
className={`timeline-marker-drag ${isDragging ? 'dragging' : ''}`}
onMouseDown={startDrag}
onTouchStart={startTouchDrag}
>
<span className="timeline-marker-drag-icon"></span>
</div>
</div> </div>
{/* Trim Line Markers - hidden when segments exist */} {/* Trim Line Markers - hidden when segments exist */}
@ -2461,139 +2581,82 @@ const TimelineControls = ({
> >
<img src={playFromBeginningIcon} alt="Play from beginning" style={{width: '24px', height: '24px'}} /> <img src={playFromBeginningIcon} alt="Play from beginning" style={{width: '24px', height: '24px'}} />
</button> </button>
<button {/* <button
className={`tooltip-action-btn ${isPlayingSegment ? 'pause' : 'play'}`} className={`tooltip-action-btn ${isPlaying ? 'pause' : 'play'}`}
data-tooltip={isPlayingSegment ? "Pause playback" : "Play from current position"} data-tooltip={isPlaying ? "Pause playback" : "Play from current position"}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
// Find the selected segment // Find the current segment
const segment = clipSegments.find(seg => seg.id === selectedSegmentId); const currentSegment = clipSegments.find(seg =>
if (segment && videoRef.current) { currentTime >= seg.startTime && currentTime <= seg.endTime
if (isPlayingSegment) {
// If already playing, pause the video
videoRef.current.pause();
setIsPlayingSegment(false);
// Reset continuePastBoundary when stopping playback
setContinuePastBoundary(false);
logger.debug("Pause clicked - resetting continuePastBoundary flag");
} else {
// Enable continuePastBoundary flag when user explicitly clicks play
// This will allow playback to continue even if we're at segment boundary
setContinuePastBoundary(true);
logger.debug("Setting continuePastBoundary=true to allow playback through boundaries");
// Keep current position (use the current time marker) and just start playing
// Don't seek to segment start - this allows continuing from where the marker is
logger.debug("Play from current position - initial time:", formatDetailedTime(videoRef.current.currentTime));
// Determine if we're at the segment end
// Exact check - we want to be very precise here
const isExactlyAtEnd = Math.abs(videoRef.current.currentTime - segment.endTime) < 0.001;
// Near check - for cases where we're very close but not exactly at the end
const isNearEnd = Math.abs(videoRef.current.currentTime - segment.endTime) < 0.05;
if (isExactlyAtEnd || isNearEnd) {
// Check if there's a segment immediately after this one
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
const nextSegmentIndex = sortedSegments.findIndex(seg => seg.id === segment.id) + 1;
const nextSegment = nextSegmentIndex < sortedSegments.length ? sortedSegments[nextSegmentIndex] : null;
// If there's an adjacent segment (no gap between segments)
if (nextSegment && Math.abs(nextSegment.startTime - segment.endTime) < 0.1) {
// Move to the start of the next segment
logger.debug(`At segment boundary: Moving to adjacent segment ${nextSegment.id}`);
videoRef.current.currentTime = nextSegment.startTime;
setSelectedSegmentId(nextSegment.id);
setActiveSegment(nextSegment);
setDisplayTime(nextSegment.startTime);
setClickedTime(nextSegment.startTime);
// Play from this next segment
videoRef.current.play()
.then(() => {
setIsPlayingSegment(true);
logger.debug("Playing from adjacent segment");
})
.catch(err => {
console.error("Error playing from adjacent segment:", err);
});
return; // Exit early since we've handled this case
}
// If we're at or near the segment end, move significantly past it
// This ensures we completely bypass the end boundary
const newPosition = segment.endTime + 0.5; // Move half a second past end
logger.debug("At segment end - repositioning to continue beyond segment boundary:",
formatDetailedTime(videoRef.current.currentTime),
"->", formatDetailedTime(newPosition)
); );
videoRef.current.currentTime = newPosition; if (isPlaying) {
// If playing, just pause
// Don't set active segment for boundary checking if (videoRef.current) {
// to allow playback to continue past the segment videoRef.current.pause();
setActiveSegment(null); setIsPlayingSegment(false);
setContinuePastBoundary(false);
// Set a flag in sessionStorage to remember we're in "continue past segment" mode }
// This is an extra safeguard against reactivation of boundary checking
sessionStorage.setItem('continuingPastSegment', 'true');
sessionStorage.setItem('lastSegmentId', segment.id.toString());
logger.debug("Continuing past segment boundary mode activated");
} else { } else {
// Normal case - not at segment end // If starting playback, set the active segment
// Make sure we're within the segment's bounds if (currentSegment) {
const isWithinSegment = setActiveSegment(currentSegment);
videoRef.current.currentTime >= segment.startTime &&
videoRef.current.currentTime <= segment.endTime;
logger.debug("Current position check:", {
currentTime: formatDetailedTime(videoRef.current.currentTime),
segmentStart: formatDetailedTime(segment.startTime),
segmentEnd: formatDetailedTime(segment.endTime),
isWithinSegment: isWithinSegment
});
// Only adjust position if we're outside the segment bounds
if (!isWithinSegment) {
// If outside segment bounds, move to current marker position
// or to segment start if marker is before segment
const newPosition = Math.max(segment.startTime, Math.min(segment.endTime, currentTime));
logger.debug("Adjusting position to be within segment:", formatDetailedTime(newPosition));
videoRef.current.currentTime = newPosition;
} else {
logger.debug("Keeping current position for playback");
} }
// Set active segment for boundary checking // Reset continuation flag when starting new playback
setActiveSegment(segment); setContinuePastBoundary(false);
logger.debug("Set active segment for boundary checking:", segment.id);
}
// Play the video from the current position if (videoRef.current) {
videoRef.current.play() videoRef.current.play()
.then(() => { .then(() => {
setIsPlayingSegment(true); setIsPlayingSegment(true);
logger.debug("Play clicked - continuing from current position:", formatDetailedTime(videoRef.current?.currentTime || 0));
}) })
.catch(err => { .catch(err => {
console.error("Error starting playback:", err); console.error("Error playing video:", err);
setIsPlayingSegment(false);
}); });
} }
} }
// Don't close the tooltip, keep it visible while playing
}} }}
> >
{isPlayingSegment ? ( {isPlaying ? (
<img src={pauseIcon} alt="Pause" style={{width: '24px', height: '24px'}} />
) : (
<img src={playIcon} alt="Play" style={{width: '24px', height: '24px'}} />
)}
</button> */}
{/* Play/Pause button for empty space - Same as main play/pause button */}
<button
className={`tooltip-action-btn ${isPlaying ? 'pause' : 'play'}`}
data-tooltip={isPlaying ? "Pause playback" : "Play from current position"}
onClick={(e) => {
e.stopPropagation();
if (isPlaying) {
// If playing, just pause
if (videoRef.current) {
videoRef.current.pause();
setIsPlayingSegment(false);
setContinuePastBoundary(false);
}
} else {
onPlayPause();
}
}}
>
{isPlaying ? (
<img src={pauseIcon} alt="Pause" style={{width: '24px', height: '24px'}} /> <img src={pauseIcon} alt="Pause" style={{width: '24px', height: '24px'}} />
) : ( ) : (
<img src={playIcon} alt="Play" style={{width: '24px', height: '24px'}} /> <img src={playIcon} alt="Play" style={{width: '24px', height: '24px'}} />
)} )}
</button> </button>
<button <button
className="tooltip-action-btn set-in" className="tooltip-action-btn set-in"
data-tooltip="Set start point at current position" data-tooltip="Set start point at current position"
@ -2980,15 +3043,16 @@ const TimelineControls = ({
<img src={playFromBeginningIcon} alt="Play from beginning" style={{width: '24px', height: '24px'}} /> <img src={playFromBeginningIcon} alt="Play from beginning" style={{width: '24px', height: '24px'}} />
</button> </button>
{/* Play/Pause button for empty space */} {/* Play/Pause button for empty space */}
<button {/* <button
className={`tooltip-action-btn ${isPlayingSegment ? 'pause' : 'play'}`} className={`tooltip-action-btn ${isPlaying ? 'pause' : 'play'}`}
data-tooltip={isPlayingSegment ? "Pause playback" : "Play from here until next segment"} data-tooltip={isPlaying ? "Pause playback" : "Play from here until next segment"}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (videoRef.current) { if (videoRef.current) {
if (isPlayingSegment) { if (isPlaying) {
// If already playing, pause the video // If already playing, pause the video
videoRef.current.pause(); videoRef.current.pause();
setIsPlayingSegment(false); setIsPlayingSegment(false);
@ -3216,7 +3280,36 @@ const TimelineControls = ({
} }
}} }}
> >
{isPlayingSegment ? ( {isPlaying ? (
<img src={pauseIcon} alt="Pause" style={{width: '24px', height: '24px'}} />
) : (
<img src={playIcon} alt="Play" style={{width: '24px', height: '24px'}} />
)}
</button> */}
{/* Play/Pause button for empty space - Same as main play/pause button */}
<button
className={`tooltip-action-btn ${isPlaying ? 'pause' : 'play'}`}
data-tooltip={isPlaying ? "Pause playback" : "Play from here until next segment"}
onClick={(e) => {
e.stopPropagation();
if (isPlaying) {
// If playing, just pause
if (videoRef.current) {
videoRef.current.pause();
setIsPlayingSegment(false);
setContinuePastBoundary(false);
}
} else {
onPlayPause();
}
}}
>
{isPlaying ? (
<img src={pauseIcon} alt="Pause" style={{width: '24px', height: '24px'}} /> <img src={pauseIcon} alt="Pause" style={{width: '24px', height: '24px'}} />
) : ( ) : (
<img src={playIcon} alt="Play" style={{width: '24px', height: '24px'}} /> <img src={playIcon} alt="Play" style={{width: '24px', height: '24px'}} />

View File

@ -1,5 +1,6 @@
import { useRef, useEffect, useState } from "react"; import React, { useRef, useEffect, useState } from "react";
import { formatTime, formatDetailedTime } from "@/lib/timeUtils"; import { formatTime, formatDetailedTime } from "@/lib/timeUtils";
import logger from '../lib/logger';
import '../styles/VideoPlayer.css'; import '../styles/VideoPlayer.css';
interface VideoPlayerProps { interface VideoPlayerProps {
@ -13,7 +14,7 @@ interface VideoPlayerProps {
onToggleMute?: () => void; onToggleMute?: () => void;
} }
const VideoPlayer = ({ const VideoPlayer: React.FC<VideoPlayerProps> = ({
videoRef, videoRef,
currentTime, currentTime,
duration, duration,
@ -22,7 +23,7 @@ const VideoPlayer = ({
onPlayPause, onPlayPause,
onSeek, onSeek,
onToggleMute onToggleMute
}: VideoPlayerProps) => { }) => {
const progressRef = useRef<HTMLDivElement>(null); const progressRef = useRef<HTMLDivElement>(null);
const [isIOS, setIsIOS] = useState(false); const [isIOS, setIsIOS] = useState(false);
const [hasInitialized, setHasInitialized] = useState(false); const [hasInitialized, setHasInitialized] = useState(false);
@ -34,7 +35,7 @@ const VideoPlayer = ({
const sampleVideoUrl = typeof window !== 'undefined' && const sampleVideoUrl = typeof window !== 'undefined' &&
(window as any).MEDIA_DATA?.videoUrl || (window as any).MEDIA_DATA?.videoUrl ||
"https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"; "/videos/sample-video-30s.mp4";
// Detect iOS device // Detect iOS device
useEffect(() => { useEffect(() => {
@ -64,14 +65,48 @@ const VideoPlayer = ({
// Add iOS-specific attributes to prevent fullscreen playback // Add iOS-specific attributes to prevent fullscreen playback
useEffect(() => { useEffect(() => {
if (videoRef.current) { const video = videoRef.current;
if (!video) return;
// These attributes need to be set directly on the DOM element // These attributes need to be set directly on the DOM element
// for iOS Safari to respect inline playback // for iOS Safari to respect inline playback
videoRef.current.setAttribute('playsinline', 'true'); video.setAttribute('playsinline', 'true');
videoRef.current.setAttribute('webkit-playsinline', 'true'); video.setAttribute('webkit-playsinline', 'true');
videoRef.current.setAttribute('x-webkit-airplay', 'allow'); video.setAttribute('x-webkit-airplay', 'allow');
// Store the last known good position for iOS
const handleTimeUpdate = () => {
if (!isDraggingProgressRef.current) {
setLastPosition(video.currentTime);
if (typeof window !== 'undefined') {
window.lastSeekedPosition = video.currentTime;
} }
}, [videoRef]); }
};
// Handle iOS-specific play/pause state
const handlePlay = () => {
logger.debug('Video play event fired');
if (isIOS) {
setHasInitialized(true);
localStorage.setItem('video_initialized', 'true');
}
};
const handlePause = () => {
logger.debug('Video pause event fired');
};
video.addEventListener('timeupdate', handleTimeUpdate);
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
return () => {
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
};
}, [videoRef, isIOS, isDraggingProgressRef]);
// Save current time to lastPosition when it changes (from external seeking) // Save current time to lastPosition when it changes (from external seeking)
useEffect(() => { useEffect(() => {
@ -248,23 +283,19 @@ const VideoPlayer = ({
if (video.paused) { if (video.paused) {
// For iOS Safari: Before playing, explicitly seek to the remembered position // For iOS Safari: Before playing, explicitly seek to the remembered position
if (isIOS && lastPosition !== null && lastPosition > 0) { if (isIOS && lastPosition !== null && lastPosition > 0) {
console.log("iOS: Explicitly setting position before play:", lastPosition); logger.debug("iOS: Explicitly setting position before play:", lastPosition);
// First, seek to the position // First, seek to the position
video.currentTime = lastPosition; video.currentTime = lastPosition;
// Use a small timeout to ensure seeking is complete before play // Use a small timeout to ensure seeking is complete before play
// This is critical for iOS Safari
setTimeout(() => { setTimeout(() => {
if (videoRef.current) { if (videoRef.current) {
// Try to play with proper promise handling // Try to play with proper promise handling
videoRef.current.play() videoRef.current.play()
.then(() => { .then(() => {
console.log("iOS: Play started successfully at position:", videoRef.current?.currentTime); logger.debug("iOS: Play started successfully at position:", videoRef.current?.currentTime);
onPlayPause(); // Update parent state after successful play
// Mark as initialized
setHasInitialized(true);
localStorage.setItem('video_initialized', 'true');
}) })
.catch(err => { .catch(err => {
console.error("iOS: Error playing video:", err); console.error("iOS: Error playing video:", err);
@ -275,19 +306,18 @@ const VideoPlayer = ({
// Normal play (non-iOS or no remembered position) // Normal play (non-iOS or no remembered position)
video.play() video.play()
.then(() => { .then(() => {
console.log("Normal: Play started successfully"); logger.debug("Normal: Play started successfully");
onPlayPause(); // Update parent state after successful play
}) })
.catch(err => { .catch(err => {
console.error("Error playing video:", err); console.error("Error playing video:", err);
}); });
} }
} else { } else {
// If playing, just pause // If playing, pause and update state
video.pause(); video.pause();
}
// Call the parent component's onPlayPause to update state
onPlayPause(); onPlayPause();
}
}; };
return ( return (

View File

@ -145,6 +145,7 @@ const useVideoTrimmer = () => {
// Only update isPlaying if we're not in preview mode // Only update isPlaying if we're not in preview mode
if (!isPreviewMode) { if (!isPreviewMode) {
setIsPlaying(true); setIsPlaying(true);
setVideoInitialized(true);
} }
}; };
@ -932,11 +933,27 @@ const useVideoTrimmer = () => {
saveState('save_segments'); saveState('save_segments');
}; };
// Handle seeking with mobile check
const handleMobileSafeSeek = (time: number) => {
// Only allow seeking if not on mobile or if video has been played
if (!isMobile || videoInitialized) {
seekVideo(time);
}
};
// Check if device is mobile
const isMobile = typeof window !== 'undefined' && /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(navigator.userAgent);
// Add videoInitialized state
const [videoInitialized, setVideoInitialized] = useState(false);
return { return {
videoRef, videoRef,
currentTime, currentTime,
duration, duration,
isPlaying, isPlaying,
setIsPlaying,
isMuted,
isPreviewMode, isPreviewMode,
thumbnails, thumbnails,
trimStart, trimStart,
@ -944,25 +961,25 @@ const useVideoTrimmer = () => {
splitPoints, splitPoints,
zoomLevel, zoomLevel,
clipSegments, clipSegments,
hasUnsavedChanges,
historyPosition, historyPosition,
history, history,
isMuted,
hasUnsavedChanges, // Add unsaved changes flag to the return object
playPauseVideo,
seekVideo,
handleTrimStartChange, handleTrimStartChange,
handleTrimEndChange, handleTrimEndChange,
handleZoomChange,
handleMobileSafeSeek,
handleSplit, handleSplit,
handleReset, handleReset,
handleUndo, handleUndo,
handleRedo, handleRedo,
handlePreview, handlePreview,
handlePlay,
handleZoomChange,
toggleMute, toggleMute,
handleSave, handleSave,
handleSaveACopy, handleSaveACopy,
handleSaveSegments, handleSaveSegments,
isMobile,
videoInitialized,
setVideoInitialized,
}; };
}; };

View File

@ -65,7 +65,7 @@
background-color: white; background-color: white;
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 1rem; padding: 1rem;
margin-bottom: 1rem; margin-bottom: 2.5rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
} }

View File

@ -56,31 +56,51 @@
.timeline-marker { .timeline-marker {
position: absolute; position: absolute;
top: 0; height: 82px; /* Increased height to extend below timeline */
bottom: 0; width: 2px;
width: 1px; background-color: #000;
background-color: red; transform: translateX(-50%);
z-index: 30; z-index: 50;
pointer-events: none; pointer-events: none;
} }
.timeline-marker-head { .timeline-marker-head {
position: absolute; position: absolute;
top: -11px; top: -6px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
width: 20px; width: 16px;
height: 20px; height: 16px;
background-color: red; background-color: #ef4444;
border-radius: 50%; border-radius: 50%;
pointer-events: auto; cursor: pointer;
cursor: grab;
z-index: 31;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: transform 0.1s ease, background-color 0.1s ease; pointer-events: auto;
touch-action: none; z-index: 51;
}
.timeline-marker-drag {
position: absolute;
bottom: -12px; /* Changed from -6px to -12px to move it further down */
left: 50%;
transform: translateX(-50%);
width: 16px;
height: 16px;
background-color: #4b5563;
border-radius: 50%;
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
z-index: 51;
}
.timeline-marker-drag.dragging {
cursor: grabbing;
background-color: #374151;
} }
.timeline-marker-head-icon { .timeline-marker-head-icon {
@ -88,13 +108,16 @@
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: bold;
line-height: 1; line-height: 1;
user-select: none;
} }
.timeline-marker-head.dragging { .timeline-marker-drag-icon {
transform: translateX(-50%) scale(1.2); color: white;
cursor: grabbing; font-size: 12px;
background-color: #ff3333; line-height: 1;
box-shadow: 0 0 8px rgba(255, 0, 0, 0.6); user-select: none;
transform: rotate(90deg);
display: inline-block;
} }
.trim-line-marker { .trim-line-marker {
@ -258,27 +281,27 @@
background-color: rgba(0, 0, 0, 0.6); background-color: rgba(0, 0, 0, 0.6);
} }
.timeline-marker {
height: 52px; /* Increased height for touch devices */
}
.timeline-marker-head { .timeline-marker-head {
width: 24px; width: 24px;
height: 24px; height: 24px;
top: -13px; top: -13px;
} }
.timeline-marker-drag {
width: 24px;
height: 24px;
bottom: -18px; /* Adjusted for larger touch target */
}
.timeline-marker-head.dragging { .timeline-marker-head.dragging {
width: 28px; width: 28px;
height: 28px; height: 28px;
top: -15px; top: -15px;
} }
/* Create a larger invisible touch target */
.timeline-marker-head:before {
content: '';
position: absolute;
top: -10px;
left: -10px;
right: -10px;
bottom: -10px;
}
} }
.segment-tooltip, .segment-tooltip,
@ -292,7 +315,8 @@
min-width: 150px; min-width: 150px;
text-align: center; text-align: center;
pointer-events: auto; pointer-events: auto;
top: -90px !important; top: -100px !important;
transform: translateY(-10px);
} }
.segment-tooltip:after, .segment-tooltip:after,
@ -309,6 +333,21 @@
border-top: 5px solid white; border-top: 5px solid white;
} }
.segment-tooltip:before,
.empty-space-tooltip:before {
content: '';
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid rgba(0, 0, 0, 0.1);
z-index: -1;
}
.tooltip-time { .tooltip-time {
font-weight: 600; font-weight: 600;
font-size: 0.875rem; font-size: 0.875rem;

View File

@ -61,17 +61,36 @@
} }
.video-player-container { .video-player-container {
position: relative; position: relative;
background-color: black; width: 100%;
background: #000;
border-radius: 0.5rem; border-radius: 0.5rem;
overflow: hidden; overflow: hidden;
margin-bottom: 1rem; margin-bottom: 1rem;
aspect-ratio: 16/9; aspect-ratio: 16/9;
/* Prevent iOS Safari from showing default video controls */
-webkit-user-select: none;
user-select: none;
} }
.video-player-container video { .video-player-container video {
width: 100%; width: 100%;
height: 100%; height: 100%;
cursor: pointer; cursor: pointer;
/* Force hardware acceleration */
transform: translateZ(0);
-webkit-transform: translateZ(0);
/* Prevent iOS Safari from showing default video controls */
-webkit-user-select: none;
user-select: none;
}
/* iOS-specific styles */
@supports (-webkit-touch-callout: none) {
.video-player-container video {
/* Additional iOS optimizations */
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
}
} }
.play-pause-indicator { .play-pause-indicator {
@ -85,8 +104,14 @@
border-radius: 50%; border-radius: 50%;
opacity: 0; opacity: 0;
transition: opacity 0.3s; transition: opacity 0.3s;
pointer-events: none;
}
&::before { .video-player-container:hover .play-pause-indicator {
opacity: 1;
}
.play-pause-indicator::before {
content: ''; content: '';
position: absolute; position: absolute;
top: 50%; top: 50%;
@ -94,7 +119,7 @@
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }
&.play-icon::before { .play-pause-indicator.play-icon::before {
width: 0; width: 0;
height: 0; height: 0;
border-top: 15px solid transparent; border-top: 15px solid transparent;
@ -103,16 +128,41 @@
margin-left: 3px; margin-left: 3px;
} }
&.pause-icon::before { .play-pause-indicator.pause-icon::before {
width: 20px; width: 20px;
height: 25px; height: 25px;
border-left: 6px solid white; border-left: 6px solid white;
border-right: 6px solid white; border-right: 6px solid white;
} }
/* iOS First-play indicator */
.ios-first-play-indicator {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
} }
.video-player-container:hover .play-pause-indicator { .ios-play-message {
opacity: 1; color: white;
font-size: 1.2rem;
text-align: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.8);
border-radius: 0.5rem;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { opacity: 0.7; transform: scale(1); }
50% { opacity: 1; transform: scale(1.05); }
100% { opacity: 0.7; transform: scale(1); }
} }
.video-controls { .video-controls {

View File

@ -25,7 +25,7 @@
} }
.page-sidebar { .page-sidebar {
z-index: +6; z-index: +20;
@media (min-width: 768px) { @media (min-width: 768px) {
z-index: +5; z-index: +5;