@@ -63,7 +74,7 @@ const EditingTools = ({
{!isPreviewMode && (
diff --git a/frontend-tools/video-editor/client/src/components/IOSPlayPrompt.tsx b/frontend-tools/video-editor/client/src/components/IOSPlayPrompt.tsx
new file mode 100644
index 00000000..7dc4ff18
--- /dev/null
+++ b/frontend-tools/video-editor/client/src/components/IOSPlayPrompt.tsx
@@ -0,0 +1,77 @@
+import React, { useState, useEffect } from 'react';
+import '../styles/IOSPlayPrompt.css';
+
+interface MobilePlayPromptProps {
+ videoRef: React.RefObject;
+ onPlay: () => void;
+}
+
+const MobilePlayPrompt: React.FC = ({ videoRef, onPlay }) => {
+ const [isVisible, setIsVisible] = useState(false);
+
+ // Check if the device is mobile
+ useEffect(() => {
+ const checkIsMobile = () => {
+ // More comprehensive check for mobile/tablet devices
+ return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(navigator.userAgent);
+ };
+
+ // Always show for mobile devices on each visit
+ const isMobile = checkIsMobile();
+ setIsVisible(isMobile);
+ }, []);
+
+ // Close the prompt when video plays
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ const handlePlay = () => {
+ // Just close the prompt when video plays
+ setIsVisible(false);
+ };
+
+ video.addEventListener('play', handlePlay);
+ return () => {
+ video.removeEventListener('play', handlePlay);
+ };
+ }, [videoRef]);
+
+ const handlePlayClick = () => {
+ onPlay();
+ // Prompt will be closed by the play event handler
+ };
+
+ if (!isVisible) return null;
+
+ return (
+
+
+
Mobile Device Notice
+
+
+ For the best video editing experience on mobile devices, you need to play the video first before
+ using the timeline controls.
+
+
+
+
Please follow these steps:
+
+ Tap the button below to start the video
+ After the video starts, you can pause it
+ Then you'll be able to use all timeline controls
+
+
+
+
+ Play Video Now
+
+
+
+ );
+};
+
+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 */}
+
setIosVideoRef(ref)}
+ className="w-full rounded-md"
+ src={videoUrl}
+ controls
+ playsInline
+ webkit-playsinline="true"
+ x-webkit-airplay="allow"
+ preload="auto"
+ crossOrigin="anonymous"
+ >
+
+ Your browser doesn't support HTML5 video.
+
+
+ {/* iOS Video Skip Controls */}
+
+
+ -15s
+
+
+ +15s
+
+
+
+ {/* iOS Fine Control Buttons */}
+
+
+ -50ms
+
+
+ +50ms
+
+
+
+
+
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 = ({
}
}}
>
-
+
{/* Segment start adjustment button (always shown) */}
@@ -2778,11 +3604,8 @@ const TimelineControls = ({
}
}}
>
-
-
-
-
-
+
+
@@ -3098,7 +3921,11 @@ const TimelineControls = ({
{
+ // Reset unsaved changes flag before saving
+ if (onSave) onSave();
+ handleSaveConfirm();
+ }}
>
Confirm Save
@@ -3128,7 +3955,11 @@ const TimelineControls = ({
{
+ // Reset unsaved changes flag before saving
+ if (onSaveACopy) onSaveACopy();
+ handleSaveAsCopyConfirm();
+ }}
>
Confirm Save As Copy
@@ -3172,7 +4003,11 @@ const TimelineControls = ({
{
+ // Reset unsaved changes flag before saving
+ if (onSaveSegments) onSaveSegments();
+ handleSaveSegmentsConfirm();
+ }}
>
Save Segments
@@ -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}
-
setShowErrorModal(false)}
@@ -3244,6 +4081,16 @@ const TimelineControls = ({
{/* Dropdown was moved inside the container element */}
+
+ {/* 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 (
Your browser doesn't support HTML5 video.
+ {/* 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,
},
});