diff --git a/frontend-tools/video-editor/client/index.html b/frontend-tools/video-editor/client/index.html index c3f9f6bc..53ce1fdd 100644 --- a/frontend-tools/video-editor/client/index.html +++ b/frontend-tools/video-editor/client/index.html @@ -1,11 +1,25 @@ - + - + + Video Editor + +
diff --git a/frontend-tools/video-editor/client/src/App.tsx b/frontend-tools/video-editor/client/src/App.tsx index fc3f45bd..bb11cb79 100644 --- a/frontend-tools/video-editor/client/src/App.tsx +++ b/frontend-tools/video-editor/client/src/App.tsx @@ -1,10 +1,15 @@ +import { useRef, useEffect, useState } from "react"; import VideoPlayer from "@/components/VideoPlayer"; import TimelineControls from "@/components/TimelineControls"; import EditingTools from "@/components/EditingTools"; import ClipSegments from "@/components/ClipSegments"; +import MobilePlayPrompt from "@/components/IOSPlayPrompt"; import useVideoTrimmer from "@/hooks/useVideoTrimmer"; const App = () => { + const [isMobile, setIsMobile] = useState(false); + const [videoInitialized, setVideoInitialized] = useState(false); + const { videoRef, currentTime, @@ -38,10 +43,133 @@ const App = () => { handleSaveSegments, } = useVideoTrimmer(); + // Refs for hold-to-continue functionality + const incrementIntervalRef = useRef(null); + const decrementIntervalRef = useRef(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 + const playFromBeginning = () => { + if (videoRef.current) { + videoRef.current.currentTime = 0; + seekVideo(0); + if (!isPlaying) { + playPauseVideo(); + } + } + }; + + // Function to jump 15 seconds backward + const jumpBackward15 = () => { + const newTime = Math.max(0, currentTime - 15); + seekVideo(newTime); + }; + + // Function to jump 15 seconds forward + const jumpForward15 = () => { + const newTime = Math.min(duration, currentTime + 15); + seekVideo(newTime); + }; + + // Start continuous 50ms increment when button is held + const startIncrement = (e: React.MouseEvent | React.TouchEvent) => { + // Prevent default to avoid text selection + e.preventDefault(); + + if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current); + + // First immediate adjustment + seekVideo(Math.min(duration, currentTime + 0.05)); + + // Setup continuous adjustment + incrementIntervalRef.current = setInterval(() => { + const currentVideoTime = videoRef.current?.currentTime || 0; + const newTime = Math.min(duration, currentVideoTime + 0.05); + seekVideo(newTime); + }, 100); + }; + + // Stop continuous increment + const stopIncrement = () => { + if (incrementIntervalRef.current) { + clearInterval(incrementIntervalRef.current); + incrementIntervalRef.current = null; + } + }; + + // Start continuous 50ms decrement when button is held + const startDecrement = (e: React.MouseEvent | React.TouchEvent) => { + // Prevent default to avoid text selection + e.preventDefault(); + + if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current); + + // First immediate adjustment + seekVideo(Math.max(0, currentTime - 0.05)); + + // Setup continuous adjustment + 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 + const handleMobileSafeSeek = (time: number) => { + // Only allow seeking if not on mobile or if video has been played + if (!isMobile || videoInitialized) { + seekVideo(time); + } + }; + return (
+ +
- {/* Video Player */} { isPlaying={isPlaying} isMuted={isMuted} onPlayPause={playPauseVideo} - onSeek={seekVideo} + onSeek={handleMobileSafeSeek} onToggleMute={toggleMute} /> @@ -81,13 +209,14 @@ const App = () => { onTrimStartChange={handleTrimStartChange} onTrimEndChange={handleTrimEndChange} onZoomChange={handleZoomChange} - onSeek={seekVideo} + onSeek={handleMobileSafeSeek} videoRef={videoRef} onSave={handleSave} onSaveACopy={handleSaveACopy} onSaveSegments={handleSaveSegments} isPreviewMode={isPreviewMode} hasUnsavedChanges={hasUnsavedChanges} + isIOSUninitialized={isMobile && !videoInitialized} /> {/* Clip Segments */} diff --git a/frontend-tools/video-editor/client/src/assets/segment-end-new-cutaway.svg b/frontend-tools/video-editor/client/src/assets/segment-end-new-cutaway.svg new file mode 100644 index 00000000..3b602d88 --- /dev/null +++ b/frontend-tools/video-editor/client/src/assets/segment-end-new-cutaway.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend-tools/video-editor/client/src/assets/segment-end-new.svg b/frontend-tools/video-editor/client/src/assets/segment-end-new.svg index ef94eaf2..3b602d88 100644 --- a/frontend-tools/video-editor/client/src/assets/segment-end-new.svg +++ b/frontend-tools/video-editor/client/src/assets/segment-end-new.svg @@ -1,9 +1,10 @@ + - - - + + + \ No newline at end of file diff --git a/frontend-tools/video-editor/client/src/assets/segment-start-new-cutaway.svg b/frontend-tools/video-editor/client/src/assets/segment-start-new-cutaway.svg new file mode 100644 index 00000000..20dc2a5c --- /dev/null +++ b/frontend-tools/video-editor/client/src/assets/segment-start-new-cutaway.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend-tools/video-editor/client/src/assets/segment-start-new.svg b/frontend-tools/video-editor/client/src/assets/segment-start-new.svg index e014b219..2b7d9751 100644 --- a/frontend-tools/video-editor/client/src/assets/segment-start-new.svg +++ b/frontend-tools/video-editor/client/src/assets/segment-start-new.svg @@ -1,6 +1,9 @@ - + + + + \ No newline at end of file diff --git a/frontend-tools/video-editor/client/src/components/EditingTools.tsx b/frontend-tools/video-editor/client/src/components/EditingTools.tsx index d1521e91..635f4859 100644 --- a/frontend-tools/video-editor/client/src/components/EditingTools.tsx +++ b/frontend-tools/video-editor/client/src/components/EditingTools.tsx @@ -25,6 +25,17 @@ const EditingTools = ({ isPreviewMode = false, isPlaying = false, }: EditingToolsProps) => { + // Handle play button click with iOS fix + const handlePlay = () => { + // Ensure lastSeekedPosition is used when play is clicked + if (typeof window !== 'undefined') { + console.log("Play button clicked, current lastSeekedPosition:", window.lastSeekedPosition); + } + + // Call the original handler + onPlay(); + }; + return (
@@ -63,7 +74,7 @@ const EditingTools = ({ {!isPreviewMode && ( +
+
+ ); +}; + +export default MobilePlayPrompt; \ No newline at end of file diff --git a/frontend-tools/video-editor/client/src/components/IOSVideoPlayer.tsx b/frontend-tools/video-editor/client/src/components/IOSVideoPlayer.tsx new file mode 100644 index 00000000..d4de0bfc --- /dev/null +++ b/frontend-tools/video-editor/client/src/components/IOSVideoPlayer.tsx @@ -0,0 +1,186 @@ +import { useEffect, useState, useRef } from "react"; +import { formatTime } from "@/lib/timeUtils"; +import '../styles/IOSVideoPlayer.css'; + +interface IOSVideoPlayerProps { + videoRef: React.RefObject; + currentTime: number; + duration: number; +} + +const IOSVideoPlayer = ({ + videoRef, + currentTime, + duration, +}: IOSVideoPlayerProps) => { + const [videoUrl, setVideoUrl] = useState(""); + const [iosVideoRef, setIosVideoRef] = useState(null); + + // Refs for hold-to-continue functionality + const incrementIntervalRef = useRef(null); + const decrementIntervalRef = useRef(null); + + // Clean up intervals on unmount + useEffect(() => { + return () => { + if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current); + if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current); + }; + }, []); + + // Get the video source URL from the main player + useEffect(() => { + if (videoRef.current && videoRef.current.querySelector('source')) { + const source = videoRef.current.querySelector('source') as HTMLSourceElement; + if (source && source.src) { + setVideoUrl(source.src); + } + } else { + // Fallback to sample video if needed + setVideoUrl("https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"); + } + }, [videoRef]); + + // Function to jump 15 seconds backward + const jumpBackward15 = () => { + if (iosVideoRef) { + const newTime = Math.max(0, iosVideoRef.currentTime - 15); + iosVideoRef.currentTime = newTime; + } + }; + + // Function to jump 15 seconds forward + const jumpForward15 = () => { + if (iosVideoRef) { + const newTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 15); + iosVideoRef.currentTime = newTime; + } + }; + + // Start continuous 50ms increment when button is held + const startIncrement = (e: React.MouseEvent | React.TouchEvent) => { + // Prevent default to avoid text selection + e.preventDefault(); + + if (!iosVideoRef) return; + if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current); + + // First immediate adjustment + iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05); + + // Setup continuous adjustment + incrementIntervalRef.current = setInterval(() => { + if (iosVideoRef) { + iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05); + } + }, 100); + }; + + // Stop continuous increment + const stopIncrement = () => { + if (incrementIntervalRef.current) { + clearInterval(incrementIntervalRef.current); + incrementIntervalRef.current = null; + } + }; + + // Start continuous 50ms decrement when button is held + const startDecrement = (e: React.MouseEvent | React.TouchEvent) => { + // Prevent default to avoid text selection + e.preventDefault(); + + if (!iosVideoRef) return; + if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current); + + // First immediate adjustment + iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05); + + // Setup continuous adjustment + decrementIntervalRef.current = setInterval(() => { + if (iosVideoRef) { + iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05); + } + }, 100); + }; + + // Stop continuous decrement + const stopDecrement = () => { + if (decrementIntervalRef.current) { + clearInterval(decrementIntervalRef.current); + decrementIntervalRef.current = null; + } + }; + + return ( +
+ {/* Current Time / Duration Display */} +
+ {formatTime(currentTime)} / {formatTime(duration)} +
+ + {/* iOS-optimized Video Element with Native Controls */} + + + {/* iOS Video Skip Controls */} +
+ + +
+ + {/* iOS Fine Control Buttons */} +
+ + +
+ +
+

This player uses native iOS controls for better compatibility with iOS devices.

+
+
+ ); +}; + +export default IOSVideoPlayer; \ No newline at end of file diff --git a/frontend-tools/video-editor/client/src/components/TimelineControls.tsx b/frontend-tools/video-editor/client/src/components/TimelineControls.tsx index 18c38241..dca2dae7 100644 --- a/frontend-tools/video-editor/client/src/components/TimelineControls.tsx +++ b/frontend-tools/video-editor/client/src/components/TimelineControls.tsx @@ -12,7 +12,8 @@ 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'; interface TimelineControlsProps { currentTime: number; duration: number; @@ -32,6 +33,7 @@ interface TimelineControlsProps { onSaveSegments?: () => void; isPreviewMode?: boolean; hasUnsavedChanges?: boolean; + isIOSUninitialized?: boolean; } // Function to calculate and constrain tooltip position to keep it on screen @@ -74,7 +76,8 @@ const TimelineControls = ({ onSaveACopy, onSaveSegments, isPreviewMode, - hasUnsavedChanges = false + hasUnsavedChanges = false, + isIOSUninitialized = false }: TimelineControlsProps) => { const timelineRef = useRef(null); const leftHandleRef = useRef(null); @@ -241,27 +244,40 @@ const TimelineControls = ({ const mediaId = typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId || null; const redirectURL = typeof window !== 'undefined' && (window as any).MEDIA_DATA?.redirectURL || null; + // Log the request details for debugging + logger.debug("Save request:", { mediaId, segments, saveAsCopy: false, redirectURL }); + const response = await trimVideo(mediaId, { segments, saveAsCopy: false }); + // Log the response for debugging + logger.debug("Save response:", response); + // Hide processing modal setShowProcessingModal(false); // Check if response indicates success (200 OK) if (response.status === 200) { // For "Save", use the redirectURL from the window or response - setRedirectUrl(redirectURL || response.url_redirect); + const finalRedirectUrl = redirectURL || response.url_redirect; + logger.debug("Using redirect URL:", finalRedirectUrl); + + setRedirectUrl(finalRedirectUrl); + setSuccessMessage("Video saved successfully!"); // Show success modal setShowSuccessModal(true); } else if (response.status === 400) { // Set error message from response and show error modal - setErrorMessage(response.error || "An error occurred during processing"); + const errorMsg = response.error || "An error occurred during processing"; + logger.debug("Save error (400):", errorMsg); + setErrorMessage(errorMsg); setShowErrorModal(true); } else { // Handle other status codes as needed + logger.debug("Save error (unknown status):", response); setErrorMessage("An unexpected error occurred"); setShowErrorModal(true); } @@ -270,7 +286,9 @@ const TimelineControls = ({ setShowProcessingModal(false); // Set error message and show error modal - setErrorMessage(error instanceof Error ? error.message : "An error occurred during processing"); + const errorMsg = error instanceof Error ? error.message : "An error occurred during processing"; + logger.debug("Save error (exception):", errorMsg); + setErrorMessage(errorMsg); setShowErrorModal(true); } }; @@ -290,11 +308,17 @@ const TimelineControls = ({ const mediaId = typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId || null; const redirectUserMediaURL = typeof window !== 'undefined' && (window as any).MEDIA_DATA?.redirectUserMediaURL || null; + + // Log the request details for debugging + logger.debug("Save as copy request:", { mediaId, segments, saveAsCopy: true, redirectUserMediaURL }); const response = await trimVideo(mediaId, { segments, saveAsCopy: true }); + + // Log the response for debugging + logger.debug("Save as copy response:", response); // Hide processing modal setShowProcessingModal(false); @@ -302,16 +326,23 @@ const TimelineControls = ({ // Check if response indicates success (200 OK) if (response.status === 200) { // For "Save As Copy", use the redirectUserMediaURL from the window - setRedirectUrl(redirectUserMediaURL || response.url_redirect); + const finalRedirectUrl = redirectUserMediaURL || response.url_redirect; + logger.debug("Using redirect user media URL:", finalRedirectUrl); + + setRedirectUrl(finalRedirectUrl); + setSuccessMessage("Video saved as a new copy!"); // Show success modal setShowSuccessModal(true); } else if (response.status === 400) { // Set error message from response and show error modal - setErrorMessage(response.error || "An error occurred during processing"); + const errorMsg = response.error || "An error occurred during processing"; + logger.debug("Save as copy error (400):", errorMsg); + setErrorMessage(errorMsg); setShowErrorModal(true); } else { // Handle other status codes as needed + logger.debug("Save as copy error (unknown status):", response); setErrorMessage("An unexpected error occurred"); setShowErrorModal(true); } @@ -320,7 +351,9 @@ const TimelineControls = ({ setShowProcessingModal(false); // Set error message and show error modal - setErrorMessage(error instanceof Error ? error.message : "An error occurred during processing"); + const errorMsg = error instanceof Error ? error.message : "An error occurred during processing"; + logger.debug("Save as copy error (exception):", errorMsg); + setErrorMessage(errorMsg); setShowErrorModal(true); } }; @@ -341,12 +374,24 @@ const TimelineControls = ({ const mediaId = typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId || null; const redirectUserMediaURL = typeof window !== 'undefined' && (window as any).MEDIA_DATA?.redirectUserMediaURL || null; + + // Log the request details for debugging + logger.debug("Save segments request:", { + mediaId, + segments, + saveAsCopy: true, + saveIndividualSegments: true, + redirectUserMediaURL + }); const response = await trimVideo(mediaId, { segments, saveAsCopy: true, saveIndividualSegments: true }); + + // Log the response for debugging + logger.debug("Save segments response:", response); // Hide processing modal setShowProcessingModal(false); @@ -354,16 +399,23 @@ const TimelineControls = ({ // Check if response indicates success (200 OK) if (response.status === 200) { // For "Save Segments", use the redirectUserMediaURL from the window - setRedirectUrl(redirectUserMediaURL || response.url_redirect); + const finalRedirectUrl = redirectUserMediaURL || response.url_redirect; + logger.debug("Using redirect user media URL for segments:", finalRedirectUrl); + + setRedirectUrl(finalRedirectUrl); + setSuccessMessage(`${segments.length} segments saved successfully!`); // Show success modal setShowSuccessModal(true); } else if (response.status === 400) { // Set error message from response and show error modal - setErrorMessage(response.error || "An error occurred during processing"); + const errorMsg = response.error || "An error occurred during processing"; + logger.debug("Save segments error (400):", errorMsg); + setErrorMessage(errorMsg); setShowErrorModal(true); } else { // Handle other status codes as needed + logger.debug("Save segments error (unknown status):", response); setErrorMessage("An unexpected error occurred"); setShowErrorModal(true); } @@ -373,7 +425,9 @@ const TimelineControls = ({ setShowProcessingModal(false); // Set error message and show error modal - setErrorMessage(error instanceof Error ? error.message : "An error occurred during processing"); + const errorMsg = error instanceof Error ? error.message : "An error occurred during processing"; + logger.debug("Save segments error (exception):", errorMsg); + setErrorMessage(errorMsg); setShowErrorModal(true); } }; @@ -446,7 +500,8 @@ const TimelineControls = ({ logger.debug("Segment playback - time remaining:", formatDetailedTime(timeLeft), "Current:", formatDetailedTime(video.currentTime), - "End:", formatDetailedTime(activeSegment.endTime) + "End:", formatDetailedTime(activeSegment.endTime), + "ContinuePastBoundary:", continuePastBoundary ); } @@ -455,6 +510,8 @@ const TimelineControls = ({ video.pause(); video.currentTime = activeSegment.endTime; setIsPlayingSegment(false); + // Reset continuePastBoundary when stopping at boundary + setContinuePastBoundary(false); logger.debug("Passed segment end - setting back to exact boundary:", formatDetailedTime(activeSegment.endTime)); return; } @@ -463,11 +520,39 @@ const TimelineControls = ({ // Use a small tolerance to ensure we stop as close as possible to boundary // But not exactly at the boundary to avoid rounding errors if (activeSegment.endTime - video.currentTime < 0.05) { - // Pause playback and set the time exactly at the end boundary - video.pause(); - video.currentTime = activeSegment.endTime; - setIsPlayingSegment(false); - logger.debug("Paused at segment end boundary:", formatDetailedTime(activeSegment.endTime)); + if (!continuePastBoundary) { + // Pause playback and set the time exactly at the end boundary + video.pause(); + video.currentTime = activeSegment.endTime; + setIsPlayingSegment(false); + logger.debug("Paused at segment end boundary:", formatDetailedTime(activeSegment.endTime)); + + // Look for the next segment after this one (for potential continuation) + const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); + const nextSegment = sortedSegments.find(seg => seg.startTime > activeSegment.endTime); + + // If there's a next segment immediately after this one, update the tooltip to show that segment + if (nextSegment && Math.abs(nextSegment.startTime - activeSegment.endTime) < 0.1) { + logger.debug("Found adjacent next segment:", nextSegment.id); + setSelectedSegmentId(nextSegment.id); + setActiveSegment(nextSegment); + setDisplayTime(nextSegment.startTime); + setClickedTime(nextSegment.startTime); + video.currentTime = nextSegment.startTime; + } + } else { + // We're continuing past the boundary + logger.debug("Continuing past segment boundary:", formatDetailedTime(activeSegment.endTime)); + + // Reset the flag after we've passed the boundary to ensure we stop at the next boundary + if (video.currentTime > activeSegment.endTime) { + setContinuePastBoundary(false); + logger.debug("Past segment boundary - resetting continuePastBoundary flag"); + // Remove the active segment to avoid boundary checking until next segment is activated + setActiveSegment(null); + sessionStorage.removeItem('continuingPastSegment'); + } + } } }; @@ -478,7 +563,7 @@ const TimelineControls = ({ video.removeEventListener('timeupdate', handleTimeUpdate); logger.debug("Segment boundary check DEACTIVATED"); }; - }, [activeSegment, isPlayingSegment, isPreviewMode]); + }, [activeSegment, isPlayingSegment, isPreviewMode, continuePastBoundary, clipSegments]); // Update display time and check for transitions between segments and empty spaces useEffect(() => { @@ -488,7 +573,7 @@ const TimelineControls = ({ if (!videoRef.current.paused) { setDisplayTime(currentTime); - // Also update clickedTime to keep them in sync when playing + // Also update clicked time to keep them in sync when playing // This ensures correct time is shown when pausing setClickedTime(currentTime); @@ -528,6 +613,22 @@ const TimelineControls = ({ // 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) { @@ -544,6 +645,13 @@ const TimelineControls = ({ 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) { @@ -740,6 +848,11 @@ const TimelineControls = ({ 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 @@ -851,8 +964,29 @@ const TimelineControls = ({ // 1. Check remaining space until the end of video const remainingDuration = Math.max(0, duration - startTime); + // Special case: If we're very close to the end of the video (within 300ms) + // return a small value to ensure the tooltip can still be shown + if (duration - startTime < 0.3) { + logger.debug("Very close to end of video, ensuring tooltip can show:", + formatDetailedTime(startTime), "video end:", formatDetailedTime(duration)); + return 0.5; // Minimum value to show tooltip + } + // 2. Find the next segment (if any) 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) + // Use a small tolerance for floating point comparison + const isAtSegmentBoundary = sortedSegments.some(seg => + Math.abs(startTime - seg.startTime) < 0.01 || + Math.abs(startTime - seg.endTime) < 0.01 + ); + + // If we're exactly at a segment boundary, return a small non-zero value to ensure tooltip shows + if (isAtSegmentBoundary) { + return 0.5; // Minimum value to show tooltip + } + const nextSegment = sortedSegments.find(seg => seg.startTime > startTime); if (nextSegment) { @@ -869,6 +1003,12 @@ const TimelineControls = ({ const handleTimelineClick = (e: React.MouseEvent) => { if (!timelineRef.current || !scrollContainerRef.current) return; + // If on mobile device and video hasn't been initialized, don't handle timeline clicks + if (isIOSUninitialized) { + // Don't do anything on timeline click if mobile device hasn't been initialized + return; + } + // Check if video is globally playing before the click const wasPlaying = videoRef.current && !videoRef.current.paused; logger.debug("Video was playing before timeline click:", wasPlaying); @@ -892,6 +1032,15 @@ const TimelineControls = ({ 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); @@ -899,10 +1048,19 @@ const TimelineControls = ({ setClickedTime(newTime); setDisplayTime(newTime); - // Find if we clicked in a segment - const segmentAtClickedTime = clipSegments.find( - seg => newTime >= seg.startTime && newTime <= seg.endTime - ); + // 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 + 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) { @@ -927,12 +1085,78 @@ const TimelineControls = ({ // Only process tooltip display if clicked on the timeline background or thumbnails, not on other UI elements if (e.target === timelineRef.current || (e.target as HTMLElement).classList.contains('timeline-thumbnail')) { - // Check if there's a segment at the clicked position - const segmentAtClickedTime = clipSegments.find( - seg => newTime >= seg.startTime && newTime <= seg.endTime + // 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 there's a segment, show its tooltip instead of the empty space tooltip + // 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 if (segmentAtClickedTime) { setSelectedSegmentId(segmentAtClickedTime.id); setShowEmptySpaceTooltip(false); @@ -1027,6 +1251,10 @@ const TimelineControls = ({ detail: { segmentId } })); + // Hide tooltip during drag + setSelectedSegmentId(null); + setShowEmptySpaceTooltip(false); + // Function to handle both mouse and touch movements const handleDragMove = (clientX: number) => { if (!isDragging || !timelineRef.current) return; @@ -1172,6 +1400,10 @@ const TimelineControls = ({ document.body.removeChild(overlay); } + // Keep tooltip hidden after drag + setSelectedSegmentId(null); + setShowEmptySpaceTooltip(false); + // Record the final position in history as a single action const finalSegments = clipSegments.map(seg => { if (seg.id === segmentId) { @@ -1364,6 +1596,8 @@ const TimelineControls = ({ if ((wasPlaying || isPreviewMode) && videoRef.current) { // Set current segment as active segment for boundary checking setActiveSegment(segment); + // Reset the continuePastBoundary flag when clicking on a segment to ensure boundaries work + setContinuePastBoundary(false); // Continue playing from the new position videoRef.current.play() .then(() => { @@ -1503,6 +1737,482 @@ const TimelineControls = ({ }); }; + // Add a new useEffect hook to listen for segment deletion events + useEffect(() => { + // Handle the segment deletion event + const handleSegmentDelete = (event: CustomEvent) => { + const { segmentId } = event.detail; + + // If the deleted segment is the one with the currently open tooltip + if (selectedSegmentId === segmentId) { + const deletedSegmentIndex = clipSegments.findIndex(seg => seg.id === segmentId); + if (deletedSegmentIndex !== -1) { + const deletedSegment = clipSegments[deletedSegmentIndex]; + + // We need the current time to check if we should show the cutaway tooltip + const currentVideoTime = currentTime; + + // Check if the current time was within the deleted segment + const wasInsideDeletedSegment = + currentVideoTime >= deletedSegment.startTime && + currentVideoTime <= deletedSegment.endTime; + + // Calculate position in the middle of the deleted segment for tooltip + 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) { + const rect = timelineRef.current.getBoundingClientRect(); + const posPercent = (timeToUse / duration) * 100; + const xPosition = rect.left + (rect.width * (posPercent / 100)); + + setTooltipPosition({ + x: xPosition, + y: rect.top - 10 + }); + + // Show the empty space tooltip + setAvailableSegmentDuration(availableSpace); + setShowEmptySpaceTooltip(true); + + logger.debug("Segment deleted, showing cutaway tooltip with available space:", + formatDetailedTime(availableSpace), + "at position:", + formatDetailedTime(timeToUse) + ); + } + } else { + // Not enough space for a new segment, hide tooltips + setShowEmptySpaceTooltip(false); + } + } + } + }; + + // 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]); + + // Add an effect to synchronize tooltip play state with video play state + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + const handlePlay = () => { + logger.debug("Video started playing from external control"); + setIsPlayingSegment(true); + }; + + const handlePause = () => { + logger.debug("Video paused from external control"); + setIsPlayingSegment(false); + }; + + video.addEventListener('play', handlePlay); + video.addEventListener('pause', handlePause); + + return () => { + video.removeEventListener('play', handlePlay); + video.removeEventListener('pause', handlePause); + }; + }, []); + + // Handle mouse movement over timeline to remember position + const handleTimelineMouseMove = (e: React.MouseEvent) => { + if (!timelineRef.current) return; + + const rect = timelineRef.current.getBoundingClientRect(); + const position = (e.clientX - rect.left) / rect.width; + const time = position * duration; + + // Ensure time is within bounds + const boundedTime = Math.max(0, Math.min(duration, time)); + + // Store position globally for iOS Safari + if (typeof window !== 'undefined') { + window.lastSeekedPosition = boundedTime; + } + }; + + // Add the dragging state and handlers to the component + + // Inside the TimelineControls component, add these new state variables + const [isDragging, setIsDragging] = useState(false); + // Add a dragging ref to track state without relying on React's state updates + const isDraggingRef = useRef(false); + + // Add drag handlers to enable dragging the timeline marker + const startDrag = (e: React.MouseEvent | React.TouchEvent) => { + // If on mobile device and video hasn't been initialized, don't allow dragging + if (isIOSUninitialized) { + return; + } + + e.stopPropagation(); // Don't trigger the timeline click + e.preventDefault(); // Prevent text selection during drag + + setIsDragging(true); + isDraggingRef.current = true; // Use ref for immediate value access + + // Show tooltip immediately when starting to drag + // Find the segment at the current time using improved matching + 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; + }); + + 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); + } + } + + // 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 to show the current position in tooltip + setClickedTime(newTime); + setDisplayTime(newTime); + + // Check if we're in a segment to show the appropriate tooltip + const segmentAtTime = clipSegments.find( + seg => newTime >= seg.startTime && newTime <= seg.endTime + ); + + // Calculate available space for new segment if needed + const availableSpace = segmentAtTime ? 0 : calculateAvailableSpace(newTime); + + if (segmentAtTime) { + // Show segment tooltip + setSelectedSegmentId(segmentAtTime.id); + setShowEmptySpaceTooltip(false); + } else if (availableSpace >= 0.5) { + // Only show tooltip if there's enough space for a minimal segment + // Show empty space tooltip + setSelectedSegmentId(null); + setShowEmptySpaceTooltip(true); + setAvailableSegmentDuration(availableSpace); + } else { + // Not enough space, don't show tooltip + setSelectedSegmentId(null); + setShowEmptySpaceTooltip(false); + } + + // Calculate and update tooltip position + if ((segmentAtTime || availableSpace >= 0.5) && timelineRef.current) { + const timelineRect = timelineRef.current.getBoundingClientRect(); + let xPos = moveEvent.clientX; // Default to cursor position + + if (zoomLevel > 1 && scrollContainerRef.current) { + // For zoomed timeline, adjust for scroll position + const visibleTimelineLeft = timelineRect.left - scrollContainerRef.current.scrollLeft; + const percentPos = newTime / duration; + xPos = visibleTimelineLeft + (timelineRect.width * percentPos); + } + + setTooltipPosition({ + x: xPos, + y: timelineRect.top - 10 + }); + } + + // Store position globally for iOS Safari + if (typeof window !== 'undefined') { + (window as any).lastSeekedPosition = newTime; + } + + // Seek to the new position + onSeek(newTime); + }; + + // 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 to show the current position in tooltip + setClickedTime(newTime); + setDisplayTime(newTime); + + // Check if we're in a segment to show the appropriate tooltip + const segmentAtTime = clipSegments.find( + seg => newTime >= seg.startTime && newTime <= seg.endTime + ); + + // Calculate available space for new segment if needed + const availableSpace = segmentAtTime ? 0 : calculateAvailableSpace(newTime); + + if (segmentAtTime) { + // Show segment tooltip + setSelectedSegmentId(segmentAtTime.id); + setShowEmptySpaceTooltip(false); + } else if (availableSpace >= 0.5) { + // Only show tooltip if there's enough space for a minimal segment + // Show empty space tooltip + setSelectedSegmentId(null); + setShowEmptySpaceTooltip(true); + setAvailableSegmentDuration(availableSpace); + } else { + // Not enough space, don't show tooltip + setSelectedSegmentId(null); + setShowEmptySpaceTooltip(false); + } + + // Calculate and update tooltip position + if ((segmentAtTime || availableSpace >= 0.5) && timelineRef.current) { + const timelineRect = timelineRef.current.getBoundingClientRect(); + let xPos = moveEvent.touches[0].clientX; // Default to touch position + + if (zoomLevel > 1 && scrollContainerRef.current) { + // For zoomed timeline, adjust for scroll position + const visibleTimelineLeft = timelineRect.left - scrollContainerRef.current.scrollLeft; + const percentPos = newTime / duration; + xPos = visibleTimelineLeft + (timelineRect.width * percentPos); + } + + setTooltipPosition({ + x: xPos, + y: timelineRect.top - 10 + }); + } + + // Store position globally for mobile browsers + 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); + // Remove these incorrect event listeners that were causing linter errors + // document.addEventListener('touchmove', handleTouchMove, { passive: false }); + // document.addEventListener('touchend', handleTouchEnd); + // document.addEventListener('touchcancel', handleTouchEnd); + }; + + // 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 + + setIsDragging(true); + isDraggingRef.current = true; // Use ref for immediate value access + + // Show tooltip immediately when starting to drag + // Find the segment at the current time using improved matching + 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; + }); + + 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); + } + } + + // 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 to show the current position in tooltip + setClickedTime(newTime); + setDisplayTime(newTime); + + // Store position globally for mobile browsers + if (typeof window !== 'undefined') { + (window as any).lastSeekedPosition = newTime; + } + + // Seek to the new position + onSeek(newTime); + }; + + // Handle touch end to stop dragging + const handleTouchEnd = () => { + setIsDragging(false); + isDraggingRef.current = false; // Update ref immediately + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleTouchEnd); + document.removeEventListener('touchcancel', handleTouchEnd); + }; + + // Add event listeners to track movement and release + document.addEventListener('touchmove', handleTouchMove, { passive: false }); + document.addEventListener('touchend', handleTouchEnd); + document.addEventListener('touchcancel', handleTouchEnd); + }; + + // Add a useEffect to log the redirect URL whenever it changes + useEffect(() => { + if (redirectUrl) { + logger.debug('Redirect URL updated:', { + redirectUrl, + saveType, + isSuccessModalOpen: showSuccessModal + }); + } + }, [redirectUrl, saveType, showSuccessModal]); + + // Add a useEffect for auto-redirection + useEffect(() => { + let countdownInterval: NodeJS.Timeout; + let redirectTimeout: NodeJS.Timeout; + + if (showSuccessModal && redirectUrl) { + // Start countdown timer + let secondsLeft = 10; + + // Update the countdown every second + countdownInterval = setInterval(() => { + secondsLeft--; + const countdownElement = document.querySelector('.countdown'); + if (countdownElement) { + countdownElement.textContent = secondsLeft.toString(); + } + + if (secondsLeft <= 0) { + clearInterval(countdownInterval); + } + }, 1000); + + // Set redirect timeout + redirectTimeout = setTimeout(() => { + // Reset unsaved changes flag before navigating away + if (onSave) onSave(); + + // Redirect to the URL + logger.debug('Automatically redirecting to:', redirectUrl); + window.location.href = redirectUrl; + }, 100000); // 10 seconds + } + + // Cleanup on unmount or when success modal closes + return () => { + if (countdownInterval) clearInterval(countdownInterval); + if (redirectTimeout) clearTimeout(redirectTimeout); + }; + }, [showSuccessModal, redirectUrl, onSave]); + return (
{/* Current Timecode with Milliseconds */} @@ -1527,9 +2237,10 @@ const TimelineControls = ({ ref={timelineRef} className="timeline-container" onClick={handleTimelineClick} - + onMouseMove={handleTimelineMouseMove} style={{ - width: `${zoomLevel === 1 ? '100%' : `${zoomLevel * 100}%`}` + width: `${zoomLevel * 100}%`, + cursor: 'pointer' }} > {/* Current Position Marker */} @@ -1538,7 +2249,7 @@ const TimelineControls = ({ style={{ left: `${currentTimePercent}%` }} >
{ // Prevent event propagation to avoid triggering the timeline container click e.stopPropagation(); @@ -1583,11 +2294,14 @@ const TimelineControls = ({ // Only show tooltip if there's enough space for a minimal segment if (availableSpace >= 0.5) { // Show empty space tooltip + setSelectedSegmentId(null); setShowEmptySpaceTooltip(true); } } } }} + onMouseDown={startDrag} + onTouchStart={startTouchDrag} > {selectedSegmentId || showEmptySpaceTooltip ? '-' : '+'} @@ -1664,8 +2378,7 @@ const TimelineControls = ({ } }); document.dispatchEvent(deleteEvent); - // Keep the tooltip open (we're removing this line) - // setSelectedSegmentId(null); + // We don't need to manually close the tooltip - our event handler will take care of updating the UI }} > @@ -1781,6 +2494,34 @@ const TimelineControls = ({ 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 @@ -2115,6 +2856,27 @@ const TimelineControls = ({ logger.debug(`Approaching boundary at ${formatDetailedTime(nextSegment.startTime)}, continuePastBoundary=${continuePastBoundary}, willStop=${shouldStop}`); } + // Also check if we've entered a different segment - we need to detect this too + const segmentAtCurrentTime = segments.find( + seg => currentPosition >= seg.startTime && currentPosition <= seg.endTime + ); + + // If we've moved directly into a segment during playback, we need to update the active segment + if (segmentAtCurrentTime && activeSegment?.id !== segmentAtCurrentTime.id) { + logger.debug(`Entered segment ${segmentAtCurrentTime.id} during cutaway playback`); + setActiveSegment(segmentAtCurrentTime); + setSelectedSegmentId(segmentAtCurrentTime.id); + setShowEmptySpaceTooltip(false); + + // Remove our boundary checker since we're now in a standard segment + videoRef.current.removeEventListener('timeupdate', checkCutawayBoundary); + + // Reset continuation flags + setContinuePastBoundary(false); + sessionStorage.removeItem('continuingPastSegment'); + return; + } + // If we've entered a segment, stop at its boundary if (shouldStop && nextSegment) { logger.debug(`CUTAWAY MANUAL BOUNDARY CHECK: Current position ${formatDetailedTime(currentPosition)} approaching segment at ${formatDetailedTime(nextSegment.startTime)} (distance: ${Math.abs(currentPosition - nextSegment.startTime).toFixed(3)}s) - STOPPING`); @@ -2131,6 +2893,14 @@ const TimelineControls = ({ setDisplayTime(nextSegment.startTime); setClickedTime(nextSegment.startTime); + // Reset continuePastBoundary when stopping at a boundary + setContinuePastBoundary(false); + + // Update tooltip to show the segment at the boundary + setSelectedSegmentId(nextSegment.id); + setShowEmptySpaceTooltip(false); + setActiveSegment(nextSegment); + // Force multiple adjustments to ensure exact precision const verifyPosition = () => { if (videoRef.current) { @@ -2239,6 +3009,33 @@ const TimelineControls = ({ const currentTime = videoRef.current.currentTime; const nextSegment = sortedSegments.find(seg => seg.startTime > currentTime); + // Check if we're at a segment boundary that we previously stopped at + const isAtSegmentBoundary = nextSegment && Math.abs(currentTime - nextSegment.startTime) < 0.05; + + if (isAtSegmentBoundary && nextSegment) { + // We're at the start of a segment - just continue into the segment rather than staying in cutaway + logger.debug(`At segment boundary: Moving into segment ${nextSegment.id}`); + + // Update UI to show segment tooltip instead of empty space tooltip + setSelectedSegmentId(nextSegment.id); + setShowEmptySpaceTooltip(false); + + // Set this segment as the active segment for boundary checking + setActiveSegment(nextSegment); + + // Play from this segment directly + videoRef.current.play() + .then(() => { + setIsPlayingSegment(true); + logger.debug("Playing from segment start after boundary"); + }) + .catch(err => { + console.error("Error starting playback:", err); + }); + + return; // Exit early as we've handled this special case + } + // Define end boundary (either next segment start or video end) const endTime = nextSegment ? nextSegment.startTime : duration; @@ -2287,6 +3084,27 @@ const TimelineControls = ({ // to catch the boundary earlier const nextSegment = segments.find(seg => seg.startTime > currentPosition - 0.3); + // Also check if we've entered a different segment - we need to detect this too + const segmentAtCurrentTime = segments.find( + seg => currentPosition >= seg.startTime && currentPosition <= seg.endTime + ); + + // If we've moved directly into a segment during playback, we need to update the active segment + if (segmentAtCurrentTime && activeSegment?.id !== segmentAtCurrentTime.id) { + logger.debug(`Entered segment ${segmentAtCurrentTime.id} during cutaway playback`); + setActiveSegment(segmentAtCurrentTime); + setSelectedSegmentId(segmentAtCurrentTime.id); + setShowEmptySpaceTooltip(false); + + // Remove our boundary checker since we're now in a standard segment + videoRef.current.removeEventListener('timeupdate', checkCutawayBoundary); + + // Reset continuation flags + setContinuePastBoundary(false); + sessionStorage.removeItem('continuingPastSegment'); + return; + } + // We need to detect boundaries much earlier to allow for time to react // This is a key fix - we need to detect the boundary BEFORE we reach it // But don't stop if we're in continuePastBoundary mode @@ -2317,6 +3135,14 @@ const TimelineControls = ({ setDisplayTime(nextSegment.startTime); setClickedTime(nextSegment.startTime); + // Reset continuePastBoundary when stopping at a boundary + setContinuePastBoundary(false); + + // Update tooltip to show the segment at the boundary + setSelectedSegmentId(nextSegment.id); + setShowEmptySpaceTooltip(false); + setActiveSegment(nextSegment); + // Force multiple adjustments to ensure exact precision const verifyPosition = () => { if (videoRef.current) { @@ -2571,7 +3397,7 @@ const TimelineControls = ({ } }} > - Set end point + Set end point {/* Segment start adjustment button (always shown) */} @@ -2778,11 +3604,8 @@ const TimelineControls = ({ } }} > - - - - - + Set start point +
@@ -3098,7 +3921,11 @@ const TimelineControls = ({ @@ -3128,7 +3955,11 @@ const TimelineControls = ({ @@ -3172,7 +4003,11 @@ const TimelineControls = ({ @@ -3192,22 +4027,22 @@ const TimelineControls = ({ setShowSuccessModal(false)} - title="Video Processed Successfully" + title="Video Edited Successfully" > -
- {redirectUrl && ( - - - - - {saveType === "save" ? "Go to media page" : - saveType === "copy" ? "Go to the media page, the new video will soon be there" : - "Go to the media page, the new video(s) will soon be there"} - - )} +
+ {/*

+ {successMessage || "Processing completed successfully!"} +

*/} + +

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

@@ -3217,16 +4052,18 @@ const TimelineControls = ({ onClose={() => setShowErrorModal(false)} title="Video Processing Error" > -
- - - - - +
+
+ + + + + +
+

+ {errorMessage} +

-

- {errorMessage} -

+ + {/* Mobile Uninitialized Overlay - Show only when on mobile and video hasn't been played yet */} + {isIOSUninitialized && ( +
+
+

Please play the video first to enable timeline controls

+
+
+
+ )}
); }; diff --git a/frontend-tools/video-editor/client/src/components/VideoPlayer.tsx b/frontend-tools/video-editor/client/src/components/VideoPlayer.tsx index e607d79c..f2bdef4e 100644 --- a/frontend-tools/video-editor/client/src/components/VideoPlayer.tsx +++ b/frontend-tools/video-editor/client/src/components/VideoPlayer.tsx @@ -1,5 +1,5 @@ -import { useRef, useEffect } from "react"; -import { formatTime } from "@/lib/timeUtils"; +import { useRef, useEffect, useState } from "react"; +import { formatTime, formatDetailedTime } from "@/lib/timeUtils"; import '../styles/VideoPlayer.css'; interface VideoPlayerProps { @@ -24,10 +24,44 @@ const VideoPlayer = ({ onToggleMute }: VideoPlayerProps) => { const progressRef = useRef(null); + const [isIOS, setIsIOS] = useState(false); + const [hasInitialized, setHasInitialized] = useState(false); + const [lastPosition, setLastPosition] = useState(null); + const [isDraggingProgress, setIsDraggingProgress] = useState(false); + const isDraggingProgressRef = useRef(false); + const [tooltipPosition, setTooltipPosition] = useState({ x: 0 }); + const [tooltipTime, setTooltipTime] = useState(0); + const sampleVideoUrl = typeof window !== 'undefined' && (window as any).MEDIA_DATA?.videoUrl || "https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"; + // Detect iOS device + useEffect(() => { + const checkIOS = () => { + const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera; + return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream; + }; + + setIsIOS(checkIOS()); + + // Check if video was previously initialized + if (typeof window !== 'undefined') { + const wasInitialized = localStorage.getItem('video_initialized') === 'true'; + setHasInitialized(wasInitialized); + } + }, []); + + // Update initialized state when video plays + useEffect(() => { + if (isPlaying && !hasInitialized) { + setHasInitialized(true); + if (typeof window !== 'undefined') { + localStorage.setItem('video_initialized', 'true'); + } + } + }, [isPlaying, hasInitialized]); + // Add iOS-specific attributes to prevent fullscreen playback useEffect(() => { if (videoRef.current) { @@ -39,25 +73,158 @@ const VideoPlayer = ({ } }, [videoRef]); + // Save current time to lastPosition when it changes (from external seeking) + useEffect(() => { + setLastPosition(currentTime); + }, [currentTime]); + // Jump 10 seconds forward const handleForward = () => { - onSeek(Math.min(currentTime + 10, duration)); + const newTime = Math.min(currentTime + 10, duration); + onSeek(newTime); + setLastPosition(newTime); }; // Jump 10 seconds backward const handleBackward = () => { - onSeek(Math.max(currentTime - 10, 0)); + const newTime = Math.max(currentTime - 10, 0); + onSeek(newTime); + setLastPosition(newTime); }; // Calculate progress percentage const progressPercentage = duration > 0 ? (currentTime / duration) * 100 : 0; - // Handle click on progress bar + // Handle start of progress bar dragging + const handleProgressDragStart = (e: React.MouseEvent) => { + e.preventDefault(); + + setIsDraggingProgress(true); + isDraggingProgressRef.current = true; + + // Get initial position + handleProgressDrag(e); + + // Set up document-level event listeners for mouse movement and release + const handleMouseMove = (moveEvent: MouseEvent) => { + if (isDraggingProgressRef.current) { + handleProgressDrag(moveEvent); + } + }; + + const handleMouseUp = () => { + setIsDraggingProgress(false); + isDraggingProgressRef.current = false; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + + // Handle progress dragging for both mouse and touch events + const handleProgressDrag = (e: MouseEvent | React.MouseEvent) => { + if (!progressRef.current) return; + + const rect = progressRef.current.getBoundingClientRect(); + const clickPosition = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const seekTime = duration * clickPosition; + + // Update tooltip position and time + setTooltipPosition({ x: e.clientX }); + setTooltipTime(seekTime); + + // Store position locally for iOS Safari - critical for timeline seeking + setLastPosition(seekTime); + + // Also store globally for integration with other components + if (typeof window !== 'undefined') { + (window as any).lastSeekedPosition = seekTime; + } + + onSeek(seekTime); + }; + + // Handle touch events for progress bar + const handleProgressTouchStart = (e: React.TouchEvent) => { + if (!progressRef.current || !e.touches[0]) return; + e.preventDefault(); + + setIsDraggingProgress(true); + isDraggingProgressRef.current = true; + + // Get initial position using touch + handleProgressTouchMove(e); + + // Set up document-level event listeners for touch movement and release + const handleTouchMove = (moveEvent: TouchEvent) => { + if (isDraggingProgressRef.current) { + handleProgressTouchMove(moveEvent); + } + }; + + const handleTouchEnd = () => { + setIsDraggingProgress(false); + isDraggingProgressRef.current = false; + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleTouchEnd); + document.removeEventListener('touchcancel', handleTouchEnd); + }; + + document.addEventListener('touchmove', handleTouchMove, { passive: false }); + document.addEventListener('touchend', handleTouchEnd); + document.addEventListener('touchcancel', handleTouchEnd); + }; + + // Handle touch dragging on progress bar + const handleProgressTouchMove = (e: TouchEvent | React.TouchEvent) => { + if (!progressRef.current) return; + + // Get the touch coordinates + const touch = 'touches' in e ? e.touches[0] : null; + if (!touch) return; + + e.preventDefault(); // Prevent scrolling while dragging + + const rect = progressRef.current.getBoundingClientRect(); + const touchPosition = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width)); + const seekTime = duration * touchPosition; + + // Update tooltip position and time + setTooltipPosition({ x: touch.clientX }); + setTooltipTime(seekTime); + + // Store position for iOS Safari + setLastPosition(seekTime); + + // Also store globally for integration with other components + if (typeof window !== 'undefined') { + (window as any).lastSeekedPosition = seekTime; + } + + onSeek(seekTime); + }; + + // Handle click on progress bar (for non-drag interactions) const handleProgressClick = (e: React.MouseEvent) => { + // If we're already dragging, don't handle the click + if (isDraggingProgress) return; + if (progressRef.current) { const rect = progressRef.current.getBoundingClientRect(); const clickPosition = (e.clientX - rect.left) / rect.width; - onSeek(duration * clickPosition); + const seekTime = duration * clickPosition; + + // Store position locally for iOS Safari - critical for timeline seeking + setLastPosition(seekTime); + + // Also store globally for integration with other components + if (typeof window !== 'undefined') { + (window as any).lastSeekedPosition = seekTime; + } + + onSeek(seekTime); } }; @@ -72,13 +239,64 @@ const VideoPlayer = ({ } }; + // Handle click on video to play/pause + const handleVideoClick = () => { + const video = videoRef.current; + if (!video) return; + + // If the video is paused, we want to play it + if (video.paused) { + // For iOS Safari: Before playing, explicitly seek to the remembered position + if (isIOS && lastPosition !== null && lastPosition > 0) { + console.log("iOS: Explicitly setting position before play:", lastPosition); + + // First, seek to the position + video.currentTime = lastPosition; + + // Use a small timeout to ensure seeking is complete before play + // This is critical for iOS Safari + setTimeout(() => { + if (videoRef.current) { + // Try to play with proper promise handling + videoRef.current.play() + .then(() => { + console.log("iOS: Play started successfully at position:", videoRef.current?.currentTime); + + // Mark as initialized + setHasInitialized(true); + localStorage.setItem('video_initialized', 'true'); + }) + .catch(err => { + console.error("iOS: Error playing video:", err); + }); + } + }, 50); + } else { + // Normal play (non-iOS or no remembered position) + video.play() + .then(() => { + console.log("Normal: Play started successfully"); + }) + .catch(err => { + console.error("Error playing video:", err); + }); + } + } else { + // If playing, just pause + video.pause(); + } + + // Call the parent component's onPlayPause to update state + onPlayPause(); + }; + return (
+ {/* iOS First-play indicator - only shown on first visit for iOS devices when not initialized */} + {isIOS && !hasInitialized && !isPlaying && ( +
+
+ Tap Play to initialize video controls +
+
+ )} + {/* Play/Pause Indicator (shows based on current state) */}
@@ -100,11 +327,13 @@ const VideoPlayer = ({ / {formatTime(duration)}
- {/* Progress Bar */} + {/* Progress Bar with enhanced dragging */}
+ + {/* Floating time tooltip when dragging */} + {isDraggingProgress && ( +
+ {formatDetailedTime(tooltipTime)} +
+ )}
{/* Controls - Mute and Fullscreen buttons */} diff --git a/frontend-tools/video-editor/client/src/hooks/useVideoTrimmer.tsx b/frontend-tools/video-editor/client/src/hooks/useVideoTrimmer.tsx index edad1ca9..f5fe3b17 100644 --- a/frontend-tools/video-editor/client/src/hooks/useVideoTrimmer.tsx +++ b/frontend-tools/video-editor/client/src/hooks/useVideoTrimmer.tsx @@ -185,13 +185,26 @@ const useVideoTrimmer = () => { if (isPlaying) { video.pause(); } else { + // iOS Safari fix: Use the last seeked position if available + if (!isPlaying && typeof window !== 'undefined' && window.lastSeekedPosition > 0) { + // Only apply this if the video is not at the same position already + // This avoids unnecessary seeking which might cause playback issues + if (Math.abs(video.currentTime - window.lastSeekedPosition) > 0.1) { + video.currentTime = window.lastSeekedPosition; + } + } // If at the end of the trim range, reset to the beginning - if (video.currentTime >= trimEnd) { + else if (video.currentTime >= trimEnd) { video.currentTime = trimStart; } + video.play() .then(() => { // Play started successfully + // Reset the last seeked position after successfully starting playback + if (typeof window !== 'undefined') { + window.lastSeekedPosition = 0; + } }) .catch(err => { console.error("Error starting playback:", err); @@ -215,6 +228,12 @@ const useVideoTrimmer = () => { video.currentTime = time; setCurrentTime(time); + // Store the position in a global state accessible to iOS Safari + // This ensures when play is pressed later, it remembers the position + if (typeof window !== 'undefined') { + window.lastSeekedPosition = time; + } + // Find segment at this position for preview mode playback if (wasInPreviewMode) { const segmentAtPosition = clipSegments.find( @@ -784,10 +803,23 @@ const useVideoTrimmer = () => { video.pause(); setIsPlaying(false); } else { + // iOS Safari fix: Check for lastSeekedPosition + if (typeof window !== 'undefined' && window.lastSeekedPosition > 0) { + // Only seek if the position is significantly different + if (Math.abs(video.currentTime - window.lastSeekedPosition) > 0.1) { + console.log("handlePlay: Using lastSeekedPosition", window.lastSeekedPosition); + video.currentTime = window.lastSeekedPosition; + } + } + // Play the video from current position with proper promise handling video.play() .then(() => { setIsPlaying(true); + // Reset lastSeekedPosition after successful play + if (typeof window !== 'undefined') { + window.lastSeekedPosition = 0; + } }) .catch(err => { console.error("Error playing video:", err); @@ -820,7 +852,9 @@ const useVideoTrimmer = () => { }; // Display JSON in alert (for demonstration purposes) - alert(JSON.stringify(saveData, null, 2)); + if (process.env.NODE_ENV === 'development') { + console.debug("Saving data:", saveData); + } // Mark as saved - no unsaved changes setHasUnsavedChanges(false); @@ -852,7 +886,9 @@ const useVideoTrimmer = () => { }; // Display JSON in alert (for demonstration purposes) - alert(JSON.stringify(saveData, null, 2)); + if (process.env.NODE_ENV === 'development') { + console.debug("Saving data as copy:", saveData); + } // Mark as saved - no unsaved changes setHasUnsavedChanges(false); @@ -882,7 +918,9 @@ const useVideoTrimmer = () => { }; // Display JSON in alert (for demonstration purposes) - alert(JSON.stringify(saveData, null, 2)); + if (process.env.NODE_ENV === 'development') { + console.debug("Saving data as segments:", saveData); + } // Mark as saved - no unsaved changes setHasUnsavedChanges(false); diff --git a/frontend-tools/video-editor/client/src/main.tsx b/frontend-tools/video-editor/client/src/main.tsx index 539e16e3..780c763a 100644 --- a/frontend-tools/video-editor/client/src/main.tsx +++ b/frontend-tools/video-editor/client/src/main.tsx @@ -7,6 +7,7 @@ if (typeof window !== 'undefined') { videoUrl: "", mediaId: "" }; + window.lastSeekedPosition = 0; } declare global { @@ -16,6 +17,7 @@ declare global { mediaId: string; }; seekToFunction?: (time: number) => void; + lastSeekedPosition: number; } } diff --git a/frontend-tools/video-editor/client/src/styles/IOSNotification.css b/frontend-tools/video-editor/client/src/styles/IOSNotification.css new file mode 100644 index 00000000..3a0c9a96 --- /dev/null +++ b/frontend-tools/video-editor/client/src/styles/IOSNotification.css @@ -0,0 +1,167 @@ +.ios-notification { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + background-color: #fffdeb; + border-bottom: 1px solid #e2e2e2; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + padding: 10px; + animation: slide-down 0.5s ease-in-out; +} + +@keyframes slide-down { + from { + transform: translateY(-100%); + } + to { + transform: translateY(0); + } +} + +.ios-notification-content { + max-width: 600px; + margin: 0 auto; + display: flex; + align-items: flex-start; + position: relative; + padding: 0 10px; +} + +.ios-notification-icon { + flex-shrink: 0; + color: #0066cc; + margin-right: 15px; + margin-top: 3px; +} + +.ios-notification-message { + flex-grow: 1; +} + +.ios-notification-message h3 { + margin: 0 0 5px 0; + font-size: 16px; + font-weight: 600; + color: #000; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +.ios-notification-message p { + margin: 0 0 8px 0; + font-size: 14px; + color: #333; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +.ios-notification-message ol { + margin: 0; + padding-left: 20px; + font-size: 14px; + color: #333; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +.ios-notification-message li { + margin-bottom: 3px; +} + +.ios-notification-close { + position: absolute; + top: 0; + right: 0; + background: none; + border: none; + color: #666; + cursor: pointer; + padding: 5px; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.2s; + -webkit-tap-highlight-color: transparent; +} + +.ios-notification-close:hover { + color: #000; +} + +/* Desktop mode button styling */ +.ios-mode-options { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 8px; +} + +.ios-desktop-mode-btn { + background-color: #0066cc; + color: white; + border: none; + border-radius: 8px; + padding: 8px 16px; + font-size: 14px; + font-weight: 500; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + margin-bottom: 6px; + cursor: pointer; + transition: background-color 0.2s; + -webkit-tap-highlight-color: transparent; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.ios-desktop-mode-btn:hover { + background-color: #0055aa; +} + +.ios-desktop-mode-btn:active { + background-color: #004499; + transform: scale(0.98); +} + +.ios-or { + font-size: 12px; + color: #666; + margin: 0 0 6px 0; + font-style: italic; +} + +/* iOS-specific styles */ +@supports (-webkit-touch-callout: none) { + .ios-notification { + padding-top: env(safe-area-inset-top); + } + + .ios-notification-close { + padding: 10px; + } +} + +/* Make sure this notification has better visibility on smaller screens */ +@media (max-width: 480px) { + .ios-notification-content { + padding: 5px; + } + + .ios-notification-message h3 { + font-size: 15px; + } + + .ios-notification-message p, + .ios-notification-message ol { + font-size: 13px; + } +} + +/* Add iOS-specific styles when in desktop mode */ +html.ios-device { + /* Force the content to be rendered at desktop width */ + min-width: 1024px; + overflow-x: auto; +} + +html.ios-device .ios-control-btn { + /* Make buttons easier to tap in desktop mode */ + min-height: 44px; +} \ No newline at end of file diff --git a/frontend-tools/video-editor/client/src/styles/IOSPlayPrompt.css b/frontend-tools/video-editor/client/src/styles/IOSPlayPrompt.css new file mode 100644 index 00000000..438cfd4e --- /dev/null +++ b/frontend-tools/video-editor/client/src/styles/IOSPlayPrompt.css @@ -0,0 +1,96 @@ +.mobile-play-prompt-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + backdrop-filter: blur(5px); + -webkit-backdrop-filter: blur(5px); +} + +.mobile-play-prompt { + background-color: white; + width: 90%; + max-width: 400px; + border-radius: 12px; + padding: 25px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); + text-align: center; +} + +.mobile-play-prompt h3 { + margin: 0 0 15px 0; + font-size: 20px; + color: #333; + font-weight: 600; +} + +.mobile-play-prompt p { + margin: 0 0 15px 0; + font-size: 16px; + color: #444; + line-height: 1.5; +} + +.mobile-prompt-instructions { + margin: 20px 0; + text-align: left; + background-color: #f8f9fa; + padding: 15px; + border-radius: 8px; +} + +.mobile-prompt-instructions p { + margin: 0 0 8px 0; + font-size: 15px; + font-weight: 500; +} + +.mobile-prompt-instructions ol { + margin: 0; + padding-left: 22px; +} + +.mobile-prompt-instructions li { + margin-bottom: 8px; + font-size: 14px; + color: #333; +} + +.mobile-play-button { + background-color: #007bff; + color: white; + border: none; + border-radius: 8px; + padding: 12px 25px; + font-size: 16px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; + margin-top: 5px; + /* Make button easier to tap on mobile */ + min-height: 44px; + min-width: 200px; +} + +.mobile-play-button:hover { + background-color: #0069d9; +} + +.mobile-play-button:active { + background-color: #0062cc; + transform: scale(0.98); +} + +/* Special styles for mobile devices */ +@supports (-webkit-touch-callout: none) { + .mobile-play-button { + /* Extra spacing for mobile */ + padding: 14px 25px; + } +} \ No newline at end of file diff --git a/frontend-tools/video-editor/client/src/styles/IOSVideoPlayer.css b/frontend-tools/video-editor/client/src/styles/IOSVideoPlayer.css new file mode 100644 index 00000000..3b671b34 --- /dev/null +++ b/frontend-tools/video-editor/client/src/styles/IOSVideoPlayer.css @@ -0,0 +1,94 @@ +.ios-video-player-container { + position: relative; + background-color: #f8f8f8; + border: 1px solid #e2e2e2; + border-radius: 0.5rem; + padding: 1rem; + margin-bottom: 1rem; + overflow: hidden; +} + +.ios-video-player-container video { + width: 100%; + height: auto; + max-height: 360px; + aspect-ratio: 16/9; + background-color: black; +} + +.ios-time-display { + display: flex; + justify-content: center; + align-items: center; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + color: #333; +} + +.ios-note { + text-align: center; + color: #777; + font-size: 0.8rem; + padding: 0.5rem 0; +} + +/* iOS-specific styling tweaks */ +@supports (-webkit-touch-callout: none) { + .ios-video-player-container video { + max-height: 50vh; /* Use viewport height on iOS */ + } + + /* Improve controls visibility on iOS */ + video::-webkit-media-controls { + opacity: 1 !important; + visibility: visible !important; + } + + /* Ensure controls don't disappear too quickly */ + video::-webkit-media-controls-panel { + transition-duration: 3s !important; + } +} + +/* External controls styling */ +.ios-external-controls { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.5rem; +} + +.ios-control-btn { + font-weight: bold; + min-width: 100px; + height: 44px; /* Minimum touch target size for iOS */ + border: none; + border-radius: 8px; + transition: all 0.2s ease; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + -webkit-tap-highlight-color: transparent; /* Remove tap highlight on iOS */ +} + +.ios-control-btn:active { + transform: scale(0.98); + opacity: 0.9; +} + +/* Prevent text selection on buttons */ +.no-select { + -webkit-touch-callout: none; /* iOS Safari */ + -webkit-user-select: none; /* Safari */ + -khtml-user-select: none; /* Konqueror HTML */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; /* Non-prefixed version, supported by Chrome and Opera */ + cursor: default; +} + +/* Specifically prevent default behavior on fine controls */ +.ios-fine-controls button, +.ios-external-controls .no-select { + touch-action: manipulation; + -webkit-touch-callout: none; + -webkit-user-select: none; + pointer-events: auto; +} \ No newline at end of file diff --git a/frontend-tools/video-editor/client/src/styles/Modal.css b/frontend-tools/video-editor/client/src/styles/Modal.css index 6716fc47..f5d51349 100644 --- a/frontend-tools/video-editor/client/src/styles/Modal.css +++ b/frontend-tools/video-editor/client/src/styles/Modal.css @@ -70,6 +70,8 @@ color: #333; font-size: 1rem; line-height: 1.5; + max-height: 400px; + overflow-y: auto; } .modal-actions { @@ -155,6 +157,28 @@ font-size: 2rem; } +.modal-success-icon svg { + width: 60px; + height: 60px; + color: #4CAF50; + animation: success-pop 0.5s ease-out; +} + +@keyframes success-pop { + 0% { + transform: scale(0); + opacity: 0; + } + 70% { + transform: scale(1.1); + opacity: 1; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + .modal-error-icon { display: flex; justify-content: center; @@ -163,6 +187,28 @@ font-size: 2rem; } +.modal-error-icon svg { + width: 60px; + height: 60px; + color: #F44336; + animation: error-pop 0.5s ease-out; +} + +@keyframes error-pop { + 0% { + transform: scale(0); + opacity: 0; + } + 70% { + transform: scale(1.1); + opacity: 1; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + .modal-choices { display: flex; flex-direction: column; @@ -172,9 +218,9 @@ .modal-choice-button { padding: 12px 16px; - border: 1px solid #ddd; + border: none; border-radius: 4px; - background-color: #f8f8f8; + background-color: #0066cc; text-align: center; cursor: pointer; transition: all 0.2s; @@ -183,18 +229,27 @@ justify-content: center; font-weight: 500; text-decoration: none; - color: #333; + color: white; } .modal-choice-button:hover { - background-color: #eee; - border-color: #ccc; + background-color: #0055aa; + transform: translateY(-1px); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); } .modal-choice-button svg { margin-right: 8px; } +.success-link { + background-color: #4CAF50; +} + +.success-link:hover { + background-color: #3d8b40; +} + .centered-choice { margin: 0 auto; width: auto; @@ -220,4 +275,28 @@ width: 100%; } } + +.error-message { + color: #F44336; + font-weight: 500; + background-color: rgba(244, 67, 54, 0.1); + padding: 10px; + border-radius: 4px; + border-left: 4px solid #F44336; + margin-top: 10px; +} + +.redirect-message { + margin-top: 20px; + color: #555; + font-size: 0.95rem; + padding: 0; + margin: 0; +} + +.countdown { + font-weight: bold; + color: #0066cc; + font-size: 1.1rem; +} } \ No newline at end of file diff --git a/frontend-tools/video-editor/client/src/styles/TimelineControls.css b/frontend-tools/video-editor/client/src/styles/TimelineControls.css index 6d98b9d7..243bc3e6 100644 --- a/frontend-tools/video-editor/client/src/styles/TimelineControls.css +++ b/frontend-tools/video-editor/client/src/styles/TimelineControls.css @@ -74,11 +74,13 @@ background-color: red; border-radius: 50%; pointer-events: auto; - cursor: pointer; + cursor: grab; z-index: 31; display: flex; align-items: center; justify-content: center; + transition: transform 0.1s ease, background-color 0.1s ease; + touch-action: none; } .timeline-marker-head-icon { @@ -88,6 +90,13 @@ line-height: 1; } + .timeline-marker-head.dragging { + transform: translateX(-50%) scale(1.2); + cursor: grabbing; + background-color: #ff3333; + box-shadow: 0 0 8px rgba(255, 0, 0, 0.6); + } + .trim-line-marker { position: absolute; top: 0; @@ -248,6 +257,28 @@ .clip-segment-handle:active { background-color: rgba(0, 0, 0, 0.6); } + + .timeline-marker-head { + width: 24px; + height: 24px; + top: -13px; + } + + .timeline-marker-head.dragging { + width: 28px; + height: 28px; + 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, @@ -481,7 +512,7 @@ .save-button:hover, .save-copy-button:hover, .save-segments-button:hover { - background-color: rgba(9, 59, 109, 0.9); + background-color: #0056b3; } /* Media query for smaller screens */ @@ -580,4 +611,166 @@ pointer-events: none !important; } } + + /* Modal success and error styling */ + .modal-success-content, + .modal-error-content { + display: flex; + flex-direction: column; + align-items: center; + padding: 1rem; + text-align: center; + padding: 0; + margin: 0; + } + + .modal-success-icon, + .modal-error-icon { + margin-bottom: 1rem; + } + + .modal-success-icon svg { + color: #4CAF50; + animation: fadeIn 0.5s ease-in-out; + } + + .modal-error-icon svg { + color: #F44336; + animation: fadeIn 0.5s ease-in-out; + } + + .success-link { + background-color: #4CAF50; + color: white; + transition: background-color 0.3s; + } + + .success-link:hover { + background-color: #388E3C; + } + + .error-message { + color: #F44336; + font-weight: 500; + } + + /* Modal spinner animation */ + .modal-spinner { + display: flex; + justify-content: center; + margin: 2rem 0; + } + + .spinner { + width: 50px; + height: 50px; + border: 5px solid rgba(0, 0, 0, 0.1); + border-radius: 50%; + border-top-color: #0066cc; + animation: spin 1s ease-in-out infinite; + } + + @keyframes spin { + to { + transform: rotate(360deg); + } + } + + @keyframes fadeIn { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } + } + + /* Centered modal content */ + .text-center { + text-align: center; + } + + .modal-message { + margin-bottom: 1rem; + line-height: 1.5; + } + + .modal-choice-button { + display: flex; + align-items: center; + justify-content: center; + padding: 0.75rem 1.25rem; + background-color: #0066cc; + color: white; + border-radius: 4px; + text-decoration: none; + margin: 0 auto; + cursor: pointer; + font-weight: 500; + gap: 0.5rem; + border: none; + transition: background-color 0.3s; + } + + .modal-choice-button:hover { + background-color: #0056b3; + } + + .modal-choice-button svg { + flex-shrink: 0; + } + + .centered-choice { + margin: 0 auto; + min-width: 180px; + } +} + +/* Mobile Timeline Overlay */ +.mobile-timeline-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 50; + display: flex; + justify-content: center; + align-items: center; + border-radius: 0.5rem; + pointer-events: none; /* Allow clicks to pass through */ +} + +.mobile-timeline-message { + background-color: rgba(0, 0, 0, 0.8); + border-radius: 8px; + padding: 15px 25px; + text-align: center; + max-width: 80%; + animation: pulse 2s infinite; +} + +.mobile-timeline-message p { + color: white; + font-size: 16px; + margin: 0 0 15px 0; + font-weight: 500; +} + +.mobile-play-icon { + width: 0; + height: 0; + border-top: 15px solid transparent; + border-bottom: 15px solid transparent; + border-left: 25px solid white; + margin: 0 auto; +} + +@keyframes pulse { + 0% { opacity: 0.7; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.05); } + 100% { opacity: 0.7; transform: scale(1); } } \ No newline at end of file diff --git a/frontend-tools/video-editor/client/src/styles/VideoPlayer.css b/frontend-tools/video-editor/client/src/styles/VideoPlayer.css index 1061b69a..274bb269 100644 --- a/frontend-tools/video-editor/client/src/styles/VideoPlayer.css +++ b/frontend-tools/video-editor/client/src/styles/VideoPlayer.css @@ -149,45 +149,72 @@ } .video-progress { - width: 100%; - height: 4px; - background-color: rgba(255, 255, 255, 0.3); - border-radius: 2px; position: relative; + height: 6px; + background-color: rgba(255, 255, 255, 0.3); + border-radius: 3px; cursor: pointer; - margin-bottom: 0.75rem; - - &:hover { - height: 6px; - - .video-scrubber { - transform: translate(-50%, -50%) scale(1.2); - } - } + margin: 0 10px; + touch-action: none; /* Prevent browser handling of drag gestures */ + flex-grow: 1; + } + + .video-progress.dragging { + height: 8px; } .video-progress-fill { - height: 100%; - background-color: #ef4444; - border-radius: 2px; position: absolute; top: 0; left: 0; + height: 100%; + background-color: #ff0000; + border-radius: 3px; + pointer-events: none; } .video-scrubber { - width: 12px; - height: 12px; - background-color: #ef4444; - border-radius: 50%; position: absolute; top: 50%; - left: 0; /* This will be overridden by inline style */ - /* Fix vertical centering by adjusting transform */ transform: translate(-50%, -50%); - transition: transform 0.2s; - /* Add a small border to make it more visible */ - border: 1px solid rgba(255, 255, 255, 0.7); + width: 16px; + height: 16px; + background-color: #ff0000; + border-radius: 50%; + cursor: grab; + transition: transform 0.1s ease, width 0.1s ease, height 0.1s ease; + } + + /* Make the scrubber larger when dragging for better control */ + .video-progress.dragging .video-scrubber { + transform: translate(-50%, -50%) scale(1.2); + width: 18px; + height: 18px; + cursor: grabbing; + box-shadow: 0 0 8px rgba(255, 0, 0, 0.6); + } + + /* Enhance for touch devices */ + @media (pointer: coarse) { + .video-scrubber { + width: 20px; + height: 20px; + } + + .video-progress.dragging .video-scrubber { + width: 24px; + height: 24px; + } + + /* Create a larger invisible touch target */ + .video-scrubber:before { + content: ''; + position: absolute; + top: -10px; + left: -10px; + right: -10px; + bottom: -10px; + } } .video-controls-buttons { @@ -216,4 +243,34 @@ height: 1.25rem; } } + + /* Time tooltip that appears when dragging */ + .video-time-tooltip { + position: absolute; + top: -30px; + background-color: rgba(0, 0, 0, 0.7); + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-family: monospace; + pointer-events: none; + z-index: 1000; + white-space: nowrap; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + } + + /* Add a small arrow to the tooltip */ + .video-time-tooltip:after { + content: ''; + position: absolute; + bottom: -4px; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 4px solid rgba(0, 0, 0, 0.7); + } } \ No newline at end of file diff --git a/frontend-tools/video-editor/vite.config.ts b/frontend-tools/video-editor/vite.config.ts index 54e16e0b..07c5057b 100644 --- a/frontend-tools/video-editor/vite.config.ts +++ b/frontend-tools/video-editor/vite.config.ts @@ -2,19 +2,22 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import path from "path"; +// Get current directory +const __dirname = path.resolve(); + export default defineConfig({ plugins: [ react(), ], resolve: { alias: { - "@": path.resolve(import.meta.dirname, "client", "src"), - "@shared": path.resolve(import.meta.dirname, "shared"), + "@": path.resolve(__dirname, "client", "src"), + "@shared": path.resolve(__dirname, "shared"), }, }, - root: path.resolve(import.meta.dirname, "client"), + root: path.resolve(__dirname, "client"), build: { - outDir: path.resolve(import.meta.dirname, "dist/public"), + outDir: path.resolve(__dirname, "dist/public"), emptyOutDir: true, }, });