diff --git a/frontend-tools/video-editor/client/src/App.tsx b/frontend-tools/video-editor/client/src/App.tsx index b774d0b0..a51f6379 100644 --- a/frontend-tools/video-editor/client/src/App.tsx +++ b/frontend-tools/video-editor/client/src/App.tsx @@ -41,7 +41,7 @@ const App = () => { videoInitialized, setVideoInitialized, isPlayingSegments, - handlePlaySegments, + handlePlaySegments } = useVideoTrimmer(); // Function to play from the beginning @@ -69,31 +69,31 @@ const App = () => { const handlePlay = () => { if (!videoRef.current) return; - + const video = videoRef.current; - + // If already playing, just pause the video if (isPlaying) { video.pause(); setIsPlaying(false); return; } - + const currentPosition = Number(video.currentTime.toFixed(6)); // Fix to microsecond precision - + // Find the next stopping point based on current position let stopTime = duration; let currentSegment = null; let nextSegment = null; - + // Sort segments by start time to ensure correct order const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); - + // First, check if we're inside a segment or exactly at its start/end - currentSegment = sortedSegments.find(seg => { + currentSegment = sortedSegments.find((seg) => { const segStartTime = Number(seg.startTime.toFixed(6)); const segEndTime = Number(seg.endTime.toFixed(6)); - + // Check if we're inside the segment if (currentPosition > segStartTime && currentPosition < segEndTime) { return true; @@ -109,15 +109,15 @@ const App = () => { } return false; }); - + // If we're not in a segment, find the next segment if (!currentSegment) { - nextSegment = sortedSegments.find(seg => { + nextSegment = sortedSegments.find((seg) => { const segStartTime = Number(seg.startTime.toFixed(6)); return segStartTime > currentPosition; }); } - + // Determine where to stop based on position if (currentSegment) { // If we're in a segment, stop at its end @@ -126,113 +126,123 @@ const App = () => { // If we're in a cutaway and there's a next segment, stop at its start stopTime = Number(nextSegment.startTime.toFixed(6)); } - + // Create a boundary checker function with high precision const checkBoundary = () => { if (!video) return; - + const currentPosition = Number(video.currentTime.toFixed(6)); const timeLeft = Number((stopTime - currentPosition).toFixed(6)); - + // If we've reached or passed the boundary if (timeLeft <= 0 || currentPosition >= stopTime) { // First pause playback video.pause(); - + // Force exact position with multiple verification attempts const setExactPosition = () => { if (!video) return; - + // Set to exact boundary time video.currentTime = stopTime; handleMobileSafeSeek(stopTime); - + const actualPosition = Number(video.currentTime.toFixed(6)); const difference = Number(Math.abs(actualPosition - stopTime).toFixed(6)); - + logger.debug("Position verification:", { target: formatDetailedTime(stopTime), actual: formatDetailedTime(actualPosition), difference: difference }); - + // If we're not exactly at the target position, try one more time if (difference > 0) { video.currentTime = stopTime; handleMobileSafeSeek(stopTime); } }; - + // Multiple attempts to ensure precision, with increasing delays setExactPosition(); - setTimeout(setExactPosition, 5); // Quick first retry + setTimeout(setExactPosition, 5); // Quick first retry setTimeout(setExactPosition, 10); // Second retry setTimeout(setExactPosition, 20); // Third retry if needed setTimeout(setExactPosition, 50); // Final verification - + // Remove our boundary checker - video.removeEventListener('timeupdate', checkBoundary); + video.removeEventListener("timeupdate", checkBoundary); setIsPlaying(false); - + // Log the final position for debugging logger.debug("Stopped at position:", { target: formatDetailedTime(stopTime), actual: formatDetailedTime(video.currentTime), - type: currentSegment ? "segment end" : (nextSegment ? "next segment start" : "end of video"), - segment: currentSegment ? { - id: currentSegment.id, - start: formatDetailedTime(currentSegment.startTime), - end: formatDetailedTime(currentSegment.endTime) - } : null, - nextSegment: nextSegment ? { - id: nextSegment.id, - start: formatDetailedTime(nextSegment.startTime), - end: formatDetailedTime(nextSegment.endTime) - } : null + type: currentSegment + ? "segment end" + : nextSegment + ? "next segment start" + : "end of video", + segment: currentSegment + ? { + id: currentSegment.id, + start: formatDetailedTime(currentSegment.startTime), + end: formatDetailedTime(currentSegment.endTime) + } + : null, + nextSegment: nextSegment + ? { + id: nextSegment.id, + start: formatDetailedTime(nextSegment.startTime), + end: formatDetailedTime(nextSegment.endTime) + } + : null }); - + return; } }; - + // Start our boundary checker - video.addEventListener('timeupdate', checkBoundary); - + video.addEventListener("timeupdate", checkBoundary); + // Start playing - video.play() + video + .play() .then(() => { setIsPlaying(true); setVideoInitialized(true); logger.debug("Playback started:", { from: formatDetailedTime(currentPosition), to: formatDetailedTime(stopTime), - currentSegment: currentSegment ? { - id: currentSegment.id, - start: formatDetailedTime(currentSegment.startTime), - end: formatDetailedTime(currentSegment.endTime) - } : 'None', - nextSegment: nextSegment ? { - id: nextSegment.id, - start: formatDetailedTime(nextSegment.startTime), - end: formatDetailedTime(nextSegment.endTime) - } : 'None' + currentSegment: currentSegment + ? { + id: currentSegment.id, + start: formatDetailedTime(currentSegment.startTime), + end: formatDetailedTime(currentSegment.endTime) + } + : "None", + nextSegment: nextSegment + ? { + id: nextSegment.id, + start: formatDetailedTime(nextSegment.startTime), + end: formatDetailedTime(nextSegment.endTime) + } + : "None" }); }) - .catch(err => { + .catch((err) => { console.error("Error playing video:", err); }); }; return (
- - + +
{/* Video Player */} - { /> {/* Editing Tools */} - { /> {/* Timeline Controls */} - { // Sort segments by startTime const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime); - + // Handle delete segment click const handleDeleteSegment = (segmentId: number) => { // Create and dispatch the delete event - const deleteEvent = new CustomEvent('delete-segment', { - detail: { segmentId } + const deleteEvent = new CustomEvent("delete-segment", { + detail: { segmentId } }); document.dispatchEvent(deleteEvent); }; - + // Generate the same color background for a segment as shown in the timeline const getSegmentColorClass = (index: number) => { - // Return CSS class based on index modulo 8 + // Return CSS class based on index modulo 8 // This matches the CSS nth-child selectors in the timeline return `segment-default-color segment-color-${(index % 8) + 1}`; }; - + return (

Clip Segments

- + {sortedSegments.map((segment, index) => ( -
+
-
-
- Segment {index + 1} -
+
Segment {index + 1}
{formatTime(segment.startTime)} - {formatTime(segment.endTime)}
@@ -60,20 +55,24 @@ const ClipSegments = ({ segments }: ClipSegmentsProps) => {
-
))} - + {sortedSegments.length === 0 && (
No segments created yet. Use the split button to create segments. diff --git a/frontend-tools/video-editor/client/src/components/IOSPlayPrompt.tsx b/frontend-tools/video-editor/client/src/components/IOSPlayPrompt.tsx index 3ba77642..8719730e 100644 --- a/frontend-tools/video-editor/client/src/components/IOSPlayPrompt.tsx +++ b/frontend-tools/video-editor/client/src/components/IOSPlayPrompt.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect } from 'react'; -import '../styles/IOSPlayPrompt.css'; +import React, { useState, useEffect } from "react"; +import "../styles/IOSPlayPrompt.css"; interface MobilePlayPromptProps { videoRef: React.RefObject; @@ -13,7 +13,9 @@ const MobilePlayPrompt: React.FC = ({ videoRef, onPlay }) 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); + 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 @@ -31,9 +33,9 @@ const MobilePlayPrompt: React.FC = ({ videoRef, onPlay }) setIsVisible(false); }; - video.addEventListener('play', handlePlay); + video.addEventListener("play", handlePlay); return () => { - video.removeEventListener('play', handlePlay); + video.removeEventListener("play", handlePlay); }; }, [videoRef]); @@ -62,11 +64,8 @@ const MobilePlayPrompt: React.FC = ({ videoRef, onPlay })
  • Then you'll be able to use all timeline controls
  • */} - -
    @@ -74,4 +73,4 @@ const MobilePlayPrompt: React.FC = ({ videoRef, onPlay }) ); }; -export default MobilePlayPrompt; \ No newline at end of file +export default MobilePlayPrompt; diff --git a/frontend-tools/video-editor/client/src/components/IOSVideoPlayer.tsx b/frontend-tools/video-editor/client/src/components/IOSVideoPlayer.tsx index b2547dee..fe045628 100644 --- a/frontend-tools/video-editor/client/src/components/IOSVideoPlayer.tsx +++ b/frontend-tools/video-editor/client/src/components/IOSVideoPlayer.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useRef } from "react"; import { formatTime } from "@/lib/timeUtils"; -import '../styles/IOSVideoPlayer.css'; +import "../styles/IOSVideoPlayer.css"; interface IOSVideoPlayerProps { videoRef: React.RefObject; @@ -8,14 +8,10 @@ interface IOSVideoPlayerProps { duration: number; } -const IOSVideoPlayer = ({ - videoRef, - currentTime, - duration, -}: IOSVideoPlayerProps) => { +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); @@ -27,11 +23,11 @@ const IOSVideoPlayer = ({ 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 (videoRef.current && videoRef.current.querySelector("source")) { + const source = videoRef.current.querySelector("source") as HTMLSourceElement; if (source && source.src) { setVideoUrl(source.src); } @@ -61,13 +57,13 @@ const IOSVideoPlayer = ({ 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) { @@ -88,13 +84,13 @@ const IOSVideoPlayer = ({ 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) { @@ -115,12 +111,14 @@ const IOSVideoPlayer = ({
    {/* Current Time / Duration Display */}
    - {formatTime(currentTime)} / {formatTime(duration)} + + {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.

    @@ -183,4 +181,4 @@ const IOSVideoPlayer = ({ ); }; -export default IOSVideoPlayer; \ No newline at end of file +export default IOSVideoPlayer; diff --git a/frontend-tools/video-editor/client/src/components/Modal.tsx b/frontend-tools/video-editor/client/src/components/Modal.tsx index 9a3ff7b4..d5f27c87 100644 --- a/frontend-tools/video-editor/client/src/components/Modal.tsx +++ b/frontend-tools/video-editor/client/src/components/Modal.tsx @@ -1,5 +1,5 @@ -import React, { useEffect } from 'react'; -import '../styles/Modal.css'; +import React, { useEffect } from "react"; +import "../styles/Modal.css"; interface ModalProps { isOpen: boolean; @@ -9,36 +9,30 @@ interface ModalProps { actions?: React.ReactNode; } -const Modal: React.FC = ({ - isOpen, - onClose, - title, - children, - actions -}) => { +const Modal: React.FC = ({ isOpen, onClose, title, children, actions }) => { // Close modal when Escape key is pressed useEffect(() => { const handleEscapeKey = (event: KeyboardEvent) => { - if (event.key === 'Escape' && isOpen) { + if (event.key === "Escape" && isOpen) { onClose(); } }; - - document.addEventListener('keydown', handleEscapeKey); - + + document.addEventListener("keydown", handleEscapeKey); + // Disable body scrolling when modal is open if (isOpen) { - document.body.style.overflow = 'hidden'; + document.body.style.overflow = "hidden"; } - + return () => { - document.removeEventListener('keydown', handleEscapeKey); - document.body.style.overflow = ''; + document.removeEventListener("keydown", handleEscapeKey); + document.body.style.overflow = ""; }; }, [isOpen, onClose]); - + if (!isOpen) return null; - + // Handle click outside the modal content to close it const handleClickOutside = (event: React.MouseEvent) => { if (event.target === event.currentTarget) { @@ -48,23 +42,19 @@ const Modal: React.FC = ({ return (
    -
    e.stopPropagation()}> +
    e.stopPropagation()}>

    {title}

    -
    - -
    - {children} -
    - - {actions && ( -
    - {actions} -
    - )} + +
    {children}
    + + {actions &&
    {actions}
    }
    ); }; -export default Modal; \ No newline at end of file +export default Modal; diff --git a/frontend-tools/video-editor/client/src/components/TimelineControls.tsx b/frontend-tools/video-editor/client/src/components/TimelineControls.tsx index f93def4a..3bf7e6fe 100644 --- a/frontend-tools/video-editor/client/src/components/TimelineControls.tsx +++ b/frontend-tools/video-editor/client/src/components/TimelineControls.tsx @@ -2878,8 +2878,8 @@ const TimelineControls = ({ isPlayingSegments ? "Disabled during preview" : isPlaying - ? "Pause playback" - : "Play from current position" + ? "Pause playback" + : "Play from current position" } style={{ userSelect: "none", @@ -3142,8 +3142,8 @@ const TimelineControls = ({ isPlayingSegments ? "Disabled during preview" : availableSegmentDuration < 0.5 - ? "Not enough space for new segment" - : "Create new segment" + ? "Not enough space for new segment" + : "Create new segment" } disabled={availableSegmentDuration < 0.5 || isPlayingSegments} onClick={async (e) => { @@ -3735,8 +3735,8 @@ const TimelineControls = ({ isPlayingSegments ? "Disabled during preview" : isPlaying - ? "Pause playback" - : "Play from here until next segment" + ? "Pause playback" + : "Play from here until next segment" } style={{ userSelect: "none", diff --git a/frontend-tools/video-editor/client/src/components/VideoPlayer.tsx b/frontend-tools/video-editor/client/src/components/VideoPlayer.tsx index 7ea07056..842d5e5e 100644 --- a/frontend-tools/video-editor/client/src/components/VideoPlayer.tsx +++ b/frontend-tools/video-editor/client/src/components/VideoPlayer.tsx @@ -1,7 +1,7 @@ import React, { useRef, useEffect, useState } from "react"; import { formatTime, formatDetailedTime } from "@/lib/timeUtils"; -import logger from '../lib/logger'; -import '../styles/VideoPlayer.css'; +import logger from "../lib/logger"; +import "../styles/VideoPlayer.css"; interface VideoPlayerProps { videoRef: React.RefObject; @@ -32,37 +32,37 @@ const VideoPlayer: React.FC = ({ 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 || + + const sampleVideoUrl = + (typeof window !== "undefined" && (window as any).MEDIA_DATA?.videoUrl) || "/videos/sample-video-10m.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'; + 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'); + if (typeof window !== "undefined") { + localStorage.setItem("video_initialized", "true"); } } }, [isPlaying, hasInitialized]); - + // Add iOS-specific attributes to prevent fullscreen playback useEffect(() => { const video = videoRef.current; @@ -70,15 +70,15 @@ const VideoPlayer: React.FC = ({ // These attributes need to be set directly on the DOM element // for iOS Safari to respect inline playback - video.setAttribute('playsinline', 'true'); - video.setAttribute('webkit-playsinline', 'true'); - video.setAttribute('x-webkit-airplay', 'allow'); + video.setAttribute("playsinline", "true"); + video.setAttribute("webkit-playsinline", "true"); + video.setAttribute("x-webkit-airplay", "allow"); // Store the last known good position for iOS const handleTimeUpdate = () => { if (!isDraggingProgressRef.current) { setLastPosition(video.currentTime); - if (typeof window !== 'undefined') { + if (typeof window !== "undefined") { window.lastSeekedPosition = video.currentTime; } } @@ -86,33 +86,33 @@ const VideoPlayer: React.FC = ({ // Handle iOS-specific play/pause state const handlePlay = () => { - logger.debug('Video play event fired'); + logger.debug("Video play event fired"); if (isIOS) { setHasInitialized(true); - localStorage.setItem('video_initialized', 'true'); + localStorage.setItem("video_initialized", "true"); } }; const handlePause = () => { - logger.debug('Video pause event fired'); + logger.debug("Video pause event fired"); }; - video.addEventListener('timeupdate', handleTimeUpdate); - video.addEventListener('play', handlePlay); - video.addEventListener('pause', handlePause); + video.addEventListener("timeupdate", handleTimeUpdate); + video.addEventListener("play", handlePlay); + video.addEventListener("pause", handlePause); return () => { - video.removeEventListener('timeupdate', handleTimeUpdate); - video.removeEventListener('play', handlePlay); - video.removeEventListener('pause', handlePause); + video.removeEventListener("timeupdate", handleTimeUpdate); + video.removeEventListener("play", handlePlay); + video.removeEventListener("pause", handlePause); }; }, [videoRef, isIOS, isDraggingProgressRef]); - + // Save current time to lastPosition when it changes (from external seeking) useEffect(() => { setLastPosition(currentTime); }, [currentTime]); - + // Jump 10 seconds forward const handleForward = () => { const newTime = Math.min(currentTime + 10, duration); @@ -126,58 +126,58 @@ const VideoPlayer: React.FC = ({ onSeek(newTime); setLastPosition(newTime); }; - + // Calculate progress percentage const progressPercentage = duration > 0 ? (currentTime / duration) * 100 : 0; // 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.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('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') { + if (typeof window !== "undefined") { (window as any).lastSeekedPosition = seekTime; } - + onSeek(seekTime); }; @@ -185,59 +185,59 @@ const VideoPlayer: React.FC = ({ 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.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); + + 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; + 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') { + if (typeof window !== "undefined") { (window as any).lastSeekedPosition = seekTime; } - + onSeek(seekTime); }; @@ -245,20 +245,20 @@ const VideoPlayer: React.FC = ({ 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; 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') { + if (typeof window !== "undefined") { (window as any).lastSeekedPosition = seekTime; } - + onSeek(seekTime); } }; @@ -278,38 +278,43 @@ const VideoPlayer: React.FC = ({ 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) { logger.debug("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 setTimeout(() => { if (videoRef.current) { // Try to play with proper promise handling - videoRef.current.play() + videoRef.current + .play() .then(() => { - logger.debug("iOS: Play started successfully at position:", videoRef.current?.currentTime); + logger.debug( + "iOS: Play started successfully at position:", + videoRef.current?.currentTime + ); onPlayPause(); // Update parent state after successful play }) - .catch(err => { + .catch((err) => { console.error("iOS: Error playing video:", err); }); } }, 50); } else { // Normal play (non-iOS or no remembered position) - video.play() + video + .play() .then(() => { logger.debug("Normal: Play started successfully"); onPlayPause(); // Update parent state after successful play }) - .catch(err => { + .catch((err) => { console.error("Error playing video:", err); }); } @@ -336,19 +341,17 @@ const VideoPlayer: React.FC = ({

    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 -
    +
    Tap Play to initialize video controls
    )} - + {/* Play/Pause Indicator (shows based on current state) */} -
    - +
    + {/* Video Controls Overlay */}
    {/* Time and Duration */} @@ -356,47 +359,52 @@ const VideoPlayer: React.FC = ({ {formatTime(currentTime)} / {formatTime(duration)}
    - + {/* Progress Bar with enhanced dragging */} -
    -
    -
    - +
    +
    + {/* Floating time tooltip when dragging */} {isDraggingProgress && ( -
    +
    {formatDetailedTime(tooltipTime)}
    )}
    - + {/* Controls - Mute and Fullscreen buttons */}
    {/* Mute/Unmute Button */} {onToggleMute && ( - )} - + {/* Fullscreen Button */} -
    diff --git a/frontend-tools/video-editor/client/src/hooks/useVideoTrimmer.tsx b/frontend-tools/video-editor/client/src/hooks/useVideoTrimmer.tsx index 75457081..718ff86c 100644 --- a/frontend-tools/video-editor/client/src/hooks/useVideoTrimmer.tsx +++ b/frontend-tools/video-editor/client/src/hooks/useVideoTrimmer.tsx @@ -10,7 +10,7 @@ interface EditorState { trimEnd: number; splitPoints: number[]; clipSegments: Segment[]; - action?: string; + action?: string; } const useVideoTrimmer = () => { @@ -20,85 +20,88 @@ const useVideoTrimmer = () => { const [duration, setDuration] = useState(0); const [isPlaying, setIsPlaying] = useState(false); const [isMuted, setIsMuted] = useState(false); - + // Timeline state const [thumbnails, setThumbnails] = useState([]); const [trimStart, setTrimStart] = useState(0); const [trimEnd, setTrimEnd] = useState(0); const [splitPoints, setSplitPoints] = useState([]); const [zoomLevel, setZoomLevel] = useState(1); // Start with 1x zoom level - + // Clip segments state const [clipSegments, setClipSegments] = useState([]); - + // History state for undo/redo const [history, setHistory] = useState([]); const [historyPosition, setHistoryPosition] = useState(-1); - + // Track unsaved changes const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - + // State for playing segments const [isPlayingSegments, setIsPlayingSegments] = useState(false); const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0); - + // Monitor for history changes useEffect(() => { if (history.length > 0) { // For debugging - moved to console.debug - if (process.env.NODE_ENV === 'development') { - console.debug(`History state updated: ${history.length} entries, position: ${historyPosition}`); - // Log actions in history to help debug undo/redo - const actions = history.map((state, idx) => - `${idx}: ${state.action || 'unknown'} (segments: ${state.clipSegments.length})` + if (process.env.NODE_ENV === "development") { + console.debug( + `History state updated: ${history.length} entries, position: ${historyPosition}` ); - console.debug('History actions:', actions); + // Log actions in history to help debug undo/redo + const actions = history.map( + (state, idx) => + `${idx}: ${state.action || "unknown"} (segments: ${state.clipSegments.length})` + ); + console.debug("History actions:", actions); } - + // If there's at least one history entry and it wasn't a save operation, mark as having unsaved changes - const lastAction = history[historyPosition]?.action || ''; - if (lastAction !== 'save' && lastAction !== 'save_copy' && lastAction !== 'save_segments') { + const lastAction = history[historyPosition]?.action || ""; + if (lastAction !== "save" && lastAction !== "save_copy" && lastAction !== "save_segments") { setHasUnsavedChanges(true); } } }, [history, historyPosition]); - + // Set up page unload warning useEffect(() => { // Event handler for beforeunload const handleBeforeUnload = (e: BeforeUnloadEvent) => { if (hasUnsavedChanges) { // Standard way of showing a confirmation dialog before leaving - const message = 'Your edits will get lost if you leave the page. Do you want to continue?'; + const message = "Your edits will get lost if you leave the page. Do you want to continue?"; e.preventDefault(); e.returnValue = message; // Chrome requires returnValue to be set return message; // For other browsers } }; - + // Add event listener - window.addEventListener('beforeunload', handleBeforeUnload); - + window.addEventListener("beforeunload", handleBeforeUnload); + // Clean up return () => { - window.removeEventListener('beforeunload', handleBeforeUnload); + window.removeEventListener("beforeunload", handleBeforeUnload); }; }, [hasUnsavedChanges]); - + // Initialize video event listeners useEffect(() => { const video = videoRef.current; if (!video) return; - + const handleLoadedMetadata = () => { setDuration(video.duration); setTrimEnd(video.duration); - + // Generate placeholders and create initial segment const initializeEditor = async () => { // Generate thumbnail for initial segment const segmentThumbnail = await generateThumbnail(video, video.duration / 2); - + // Create an initial segment that spans the entire video const initialSegment: Segment = { id: 1, @@ -107,7 +110,7 @@ const useVideoTrimmer = () => { endTime: video.duration, thumbnail: segmentThumbnail }; - + // Initialize history state with the full-length segment const initialState: EditorState = { trimStart: 0, @@ -115,73 +118,73 @@ const useVideoTrimmer = () => { splitPoints: [], clipSegments: [initialSegment] }; - + setHistory([initialState]); setHistoryPosition(0); setClipSegments([initialSegment]); - + // Generate timeline thumbnails const count = 6; const interval = video.duration / count; const placeholders: string[] = []; - + for (let i = 0; i < count; i++) { const time = interval * i + interval / 2; const thumbnail = await generateThumbnail(video, time); placeholders.push(thumbnail); } - + setThumbnails(placeholders); }; - + initializeEditor(); }; - + const handleTimeUpdate = () => { setCurrentTime(video.currentTime); }; - + const handlePlay = () => { setIsPlaying(true); setVideoInitialized(true); }; - + const handlePause = () => { setIsPlaying(false); }; - + const handleEnded = () => { setIsPlaying(false); video.currentTime = trimStart; }; - + // Add event listeners - video.addEventListener('loadedmetadata', handleLoadedMetadata); - video.addEventListener('timeupdate', handleTimeUpdate); - video.addEventListener('play', handlePlay); - video.addEventListener('pause', handlePause); - video.addEventListener('ended', handleEnded); - + video.addEventListener("loadedmetadata", handleLoadedMetadata); + video.addEventListener("timeupdate", handleTimeUpdate); + video.addEventListener("play", handlePlay); + video.addEventListener("pause", handlePause); + video.addEventListener("ended", handleEnded); + return () => { // Remove event listeners - video.removeEventListener('loadedmetadata', handleLoadedMetadata); - video.removeEventListener('timeupdate', handleTimeUpdate); - video.removeEventListener('play', handlePlay); - video.removeEventListener('pause', handlePause); - video.removeEventListener('ended', handleEnded); + video.removeEventListener("loadedmetadata", handleLoadedMetadata); + video.removeEventListener("timeupdate", handleTimeUpdate); + video.removeEventListener("play", handlePlay); + video.removeEventListener("pause", handlePause); + video.removeEventListener("ended", handleEnded); }; }, []); - + // Play/pause video const playPauseVideo = () => { const video = videoRef.current; if (!video) return; - + if (isPlaying) { video.pause(); } else { // iOS Safari fix: Use the last seeked position if available - if (!isPlaying && typeof window !== 'undefined' && window.lastSeekedPosition > 0) { + 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) { @@ -192,54 +195,56 @@ const useVideoTrimmer = () => { else if (video.currentTime >= trimEnd) { video.currentTime = trimStart; } - - video.play() + + video + .play() .then(() => { // Play started successfully // Reset the last seeked position after successfully starting playback - if (typeof window !== 'undefined') { + if (typeof window !== "undefined") { window.lastSeekedPosition = 0; } }) - .catch(err => { + .catch((err) => { console.error("Error starting playback:", err); setIsPlaying(false); // Reset state if play failed }); } }; - + // Seek to a specific time const seekVideo = (time: number) => { const video = videoRef.current; if (!video) return; - + // Track if the video was playing before seeking const wasPlaying = !video.paused; - + // Update the video position 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') { + if (typeof window !== "undefined") { window.lastSeekedPosition = time; } - + // Resume playback if it was playing before if (wasPlaying) { // Play immediately without delay - video.play() + video + .play() .then(() => { setIsPlaying(true); // Update state to reflect we're playing }) - .catch(err => { + .catch((err) => { console.error("Error resuming playback:", err); setIsPlaying(false); }); } }; - + // Save the current state to history with a debounce buffer // This helps prevent multiple rapid saves for small adjustments const saveState = (action?: string) => { @@ -249,51 +254,54 @@ const useVideoTrimmer = () => { trimEnd, splitPoints: [...splitPoints], clipSegments: JSON.parse(JSON.stringify(clipSegments)), // Deep clone to avoid reference issues - action: action || 'manual_save' // Track the action that triggered this save + action: action || "manual_save" // Track the action that triggered this save }; - + // Check if state is significantly different from last saved state const lastState = history[historyPosition]; - + // Helper function to compare segments deeply const haveSegmentsChanged = () => { if (!lastState || lastState.clipSegments.length !== newState.clipSegments.length) { return true; // Different length means significant change } - + // Compare each segment's start and end times for (let i = 0; i < newState.clipSegments.length; i++) { const oldSeg = lastState.clipSegments[i]; const newSeg = newState.clipSegments[i]; - + if (!oldSeg || !newSeg) return true; - + // Check if any time values changed by more than 0.001 seconds (1ms) - if (Math.abs(oldSeg.startTime - newSeg.startTime) > 0.001 || - Math.abs(oldSeg.endTime - newSeg.endTime) > 0.001) { + if ( + Math.abs(oldSeg.startTime - newSeg.startTime) > 0.001 || + Math.abs(oldSeg.endTime - newSeg.endTime) > 0.001 + ) { return true; } } - + return false; // No significant changes found }; - - const isSignificantChange = !lastState || - lastState.trimStart !== newState.trimStart || + + const isSignificantChange = + !lastState || + lastState.trimStart !== newState.trimStart || lastState.trimEnd !== newState.trimEnd || lastState.splitPoints.length !== newState.splitPoints.length || haveSegmentsChanged(); - + // Additionally, check if there's an explicit action from a UI event const hasExplicitActionFlag = newState.action !== undefined; - + // Only proceed if this is a significant change or if explicitly requested if (isSignificantChange || hasExplicitActionFlag) { // Get the current position to avoid closure issues const currentPosition = historyPosition; - + // Use functional updates to ensure we're working with the latest state - setHistory(prevHistory => { + setHistory((prevHistory) => { // If we're not at the end of history, truncate if (currentPosition < prevHistory.length - 1) { const newHistory = prevHistory.slice(0, currentPosition + 1); @@ -303,9 +311,9 @@ const useVideoTrimmer = () => { return [...prevHistory, newState]; } }); - + // Update position using functional update - setHistoryPosition(prev => { + setHistoryPosition((prev) => { const newPosition = prev + 1; // "Saved state to history position", newPosition) return newPosition; @@ -314,36 +322,36 @@ const useVideoTrimmer = () => { // logger.debug("Skipped non-significant state save"); } }; - + // Listen for trim handle update events useEffect(() => { const handleTrimUpdate = (e: CustomEvent) => { if (e.detail) { const { time, isStart, recordHistory, action } = e.detail; - + if (isStart) { setTrimStart(time); } else { setTrimEnd(time); } - + // Only record in history if explicitly requested if (recordHistory) { // Use a small timeout to ensure the state is updated setTimeout(() => { - saveState(action || (isStart ? 'adjust_trim_start' : 'adjust_trim_end')); + saveState(action || (isStart ? "adjust_trim_start" : "adjust_trim_end")); }, 10); } } }; - - document.addEventListener('update-trim', handleTrimUpdate as EventListener); - + + document.addEventListener("update-trim", handleTrimUpdate as EventListener); + return () => { - document.removeEventListener('update-trim', handleTrimUpdate as EventListener); + document.removeEventListener("update-trim", handleTrimUpdate as EventListener); }; }, []); - + // Listen for segment update events and split-at-time events useEffect(() => { const handleUpdateSegments = (e: CustomEvent) => { @@ -352,14 +360,16 @@ const useVideoTrimmer = () => { // Default to true to ensure all segment changes are recorded const isSignificantChange = e.detail.recordHistory !== false; // Get the action type if provided - const actionType = e.detail.action || 'update_segments'; - + const actionType = e.detail.action || "update_segments"; + // Log the update details - logger.debug(`Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? "true" : "false"}`); - + logger.debug( + `Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? "true" : "false"}` + ); + // Update segment state immediately for UI feedback setClipSegments(e.detail.segments); - + // Always save state to history for non-intermediate actions if (isSignificantChange) { // A slight delay helps avoid race conditions but we need to @@ -367,7 +377,7 @@ const useVideoTrimmer = () => { setTimeout(() => { // Deep clone to ensure state is captured correctly const segmentsClone = JSON.parse(JSON.stringify(e.detail.segments)); - + // Create a complete state snapshot const stateWithAction: EditorState = { trimStart, @@ -376,12 +386,12 @@ const useVideoTrimmer = () => { clipSegments: segmentsClone, action: actionType // Store the action type in the state }; - + // Get the current history position to ensure we're using the latest value const currentHistoryPosition = historyPosition; - + // Update history with the functional pattern to avoid stale closure issues - setHistory(prevHistory => { + setHistory((prevHistory) => { // If we're not at the end of the history, truncate if (currentHistoryPosition < prevHistory.length - 1) { const newHistory = prevHistory.slice(0, currentHistoryPosition + 1); @@ -391,90 +401,95 @@ const useVideoTrimmer = () => { return [...prevHistory, stateWithAction]; } }); - + // Ensure the historyPosition is updated to the correct position - setHistoryPosition(prev => { + setHistoryPosition((prev) => { const newPosition = prev + 1; - logger.debug(`Saved state with action: ${actionType} to history position ${newPosition}`); + logger.debug( + `Saved state with action: ${actionType} to history position ${newPosition}` + ); return newPosition; }); }, 20); // Slightly increased delay to ensure state updates are complete } else { - logger.debug(`Skipped saving state to history for action: ${actionType} (recordHistory=false)`); + logger.debug( + `Skipped saving state to history for action: ${actionType} (recordHistory=false)` + ); } } }; - + const handleSplitSegment = async (e: Event) => { const customEvent = e as CustomEvent; - if (customEvent.detail && - typeof customEvent.detail.time === 'number' && - typeof customEvent.detail.segmentId === 'number') { - + if ( + customEvent.detail && + typeof customEvent.detail.time === "number" && + typeof customEvent.detail.segmentId === "number" + ) { // Get the time and segment ID from the event const timeToSplit = customEvent.detail.time; const segmentId = customEvent.detail.segmentId; - + // Move the current time to the split position seekVideo(timeToSplit); - + // Find the segment to split - const segmentToSplit = clipSegments.find(seg => seg.id === segmentId); + const segmentToSplit = clipSegments.find((seg) => seg.id === segmentId); if (!segmentToSplit) return; - + // Make sure the split point is within the segment if (timeToSplit <= segmentToSplit.startTime || timeToSplit >= segmentToSplit.endTime) { return; // Can't split outside segment boundaries } - + // Create two new segments from the split const newSegments = [...clipSegments]; - + // Remove the original segment - const segmentIndex = newSegments.findIndex(seg => seg.id === segmentId); + const segmentIndex = newSegments.findIndex((seg) => seg.id === segmentId); if (segmentIndex === -1) return; - + newSegments.splice(segmentIndex, 1); - + // Create first half of the split segment - no thumbnail needed const firstHalf: Segment = { id: Date.now(), name: `${segmentToSplit.name}-A`, startTime: segmentToSplit.startTime, endTime: timeToSplit, - thumbnail: '' // Empty placeholder - we'll use dynamic colors instead + thumbnail: "" // Empty placeholder - we'll use dynamic colors instead }; - + // Create second half of the split segment - no thumbnail needed const secondHalf: Segment = { id: Date.now() + 1, name: `${segmentToSplit.name}-B`, startTime: timeToSplit, endTime: segmentToSplit.endTime, - thumbnail: '' // Empty placeholder - we'll use dynamic colors instead + thumbnail: "" // Empty placeholder - we'll use dynamic colors instead }; - + // Add the new segments newSegments.push(firstHalf, secondHalf); - + // Sort segments by start time newSegments.sort((a, b) => a.startTime - b.startTime); - + // Update state setClipSegments(newSegments); - saveState('split_segment'); + saveState("split_segment"); } }; - + // Handle delete segment event const handleDeleteSegment = async (e: Event) => { const customEvent = e as CustomEvent; - if (customEvent.detail && typeof customEvent.detail.segmentId === 'number') { + if (customEvent.detail && typeof customEvent.detail.segmentId === "number") { const segmentId = customEvent.detail.segmentId; - + // Find and remove the segment - const newSegments = clipSegments.filter(segment => segment.id !== segmentId); - + const newSegments = clipSegments.filter((segment) => segment.id !== segmentId); + if (newSegments.length !== clipSegments.length) { // If all segments are deleted, create a new full video segment if (newSegments.length === 0 && videoRef.current) { @@ -485,9 +500,9 @@ const useVideoTrimmer = () => { name: "segment", startTime: 0, endTime: videoRef.current.duration, - thumbnail: '' // Empty placeholder - we'll use dynamic colors instead + thumbnail: "" // Empty placeholder - we'll use dynamic colors instead }; - + // Reset the trim points as well setTrimStart(0); setTrimEnd(videoRef.current.duration); @@ -497,50 +512,50 @@ const useVideoTrimmer = () => { // Just update the segments normally setClipSegments(newSegments); } - saveState('delete_segment'); + saveState("delete_segment"); } } }; - - document.addEventListener('update-segments', handleUpdateSegments as EventListener); - document.addEventListener('split-segment', handleSplitSegment as EventListener); - document.addEventListener('delete-segment', handleDeleteSegment as EventListener); - + + document.addEventListener("update-segments", handleUpdateSegments as EventListener); + document.addEventListener("split-segment", handleSplitSegment as EventListener); + document.addEventListener("delete-segment", handleDeleteSegment as EventListener); + return () => { - document.removeEventListener('update-segments', handleUpdateSegments as EventListener); - document.removeEventListener('split-segment', handleSplitSegment as EventListener); - document.removeEventListener('delete-segment', handleDeleteSegment as EventListener); + document.removeEventListener("update-segments", handleUpdateSegments as EventListener); + document.removeEventListener("split-segment", handleSplitSegment as EventListener); + document.removeEventListener("delete-segment", handleDeleteSegment as EventListener); }; }, [clipSegments, duration]); - + // Handle trim start change const handleTrimStartChange = (time: number) => { setTrimStart(time); - saveState('adjust_trim_start'); + saveState("adjust_trim_start"); }; - + // Handle trim end change const handleTrimEndChange = (time: number) => { setTrimEnd(time); - saveState('adjust_trim_end'); + saveState("adjust_trim_end"); }; - + // Handle split at current position const handleSplit = async () => { if (!videoRef.current) return; - + // Add current time to split points if not already present if (!splitPoints.includes(currentTime)) { const newSplitPoints = [...splitPoints, currentTime].sort((a, b) => a - b); setSplitPoints(newSplitPoints); - + // Generate segments based on split points const newSegments: Segment[] = []; let startTime = 0; - + for (let i = 0; i <= newSplitPoints.length; i++) { const endTime = i < newSplitPoints.length ? newSplitPoints[i] : duration; - + if (startTime < endTime) { // No need to generate thumbnails - we'll use dynamic colors newSegments.push({ @@ -548,51 +563,57 @@ const useVideoTrimmer = () => { name: `Segment ${i + 1}`, startTime, endTime, - thumbnail: '' // Empty placeholder - we'll use dynamic colors instead + thumbnail: "" // Empty placeholder - we'll use dynamic colors instead }); - + startTime = endTime; } } - + setClipSegments(newSegments); - saveState('create_split_points'); + saveState("create_split_points"); } }; - + // Handle reset of all edits const handleReset = async () => { setTrimStart(0); setTrimEnd(duration); setSplitPoints([]); - + // Create a new default segment that spans the entire video if (!videoRef.current) return; - + // No need to generate thumbnails - we'll use dynamic colors const defaultSegment: Segment = { id: Date.now(), name: "segment", startTime: 0, endTime: duration, - thumbnail: '' // Empty placeholder - we'll use dynamic colors instead + thumbnail: "" // Empty placeholder - we'll use dynamic colors instead }; - + setClipSegments([defaultSegment]); - saveState('reset_all'); + saveState("reset_all"); }; - + // Handle undo const handleUndo = () => { if (historyPosition > 0) { const previousState = history[historyPosition - 1]; - logger.debug(`** UNDO ** to position ${historyPosition - 1}, action: ${previousState.action}, segments: ${previousState.clipSegments.length}`); - + logger.debug( + `** UNDO ** to position ${historyPosition - 1}, action: ${previousState.action}, segments: ${previousState.clipSegments.length}` + ); + // Log segment details to help debug - logger.debug("Segment details after undo:", previousState.clipSegments.map(seg => - `ID: ${seg.id}, Time: ${formatDetailedTime(seg.startTime)} - ${formatDetailedTime(seg.endTime)}` - )); - + logger.debug( + "Segment details after undo:", + previousState.clipSegments.map( + (seg) => + `ID: ${seg.id}, Time: ${formatDetailedTime(seg.startTime)} - ${formatDetailedTime(seg.endTime)}` + ) + ); + // Apply the previous state with deep cloning to avoid reference issues setTrimStart(previousState.trimStart); setTrimEnd(previousState.trimEnd); @@ -603,18 +624,24 @@ const useVideoTrimmer = () => { logger.debug("Cannot undo: at earliest history position"); } }; - + // Handle redo const handleRedo = () => { if (historyPosition < history.length - 1) { const nextState = history[historyPosition + 1]; - logger.debug(`** REDO ** to position ${historyPosition + 1}, action: ${nextState.action}, segments: ${nextState.clipSegments.length}`); - + logger.debug( + `** REDO ** to position ${historyPosition + 1}, action: ${nextState.action}, segments: ${nextState.clipSegments.length}` + ); + // Log segment details to help debug - logger.debug("Segment details after redo:", nextState.clipSegments.map(seg => - `ID: ${seg.id}, Time: ${formatDetailedTime(seg.startTime)} - ${formatDetailedTime(seg.endTime)}` - )); - + logger.debug( + "Segment details after redo:", + nextState.clipSegments.map( + (seg) => + `ID: ${seg.id}, Time: ${formatDetailedTime(seg.startTime)} - ${formatDetailedTime(seg.endTime)}` + ) + ); + // Apply the next state with deep cloning to avoid reference issues setTrimStart(nextState.trimStart); setTrimEnd(nextState.trimEnd); @@ -625,151 +652,152 @@ const useVideoTrimmer = () => { logger.debug("Cannot redo: at latest history position"); } }; - + // Handle zoom level change const handleZoomChange = (level: number) => { setZoomLevel(level); }; - + // Handle play/pause of the full video const handlePlay = () => { const video = videoRef.current; if (!video) return; - + if (isPlaying) { // Pause the video video.pause(); setIsPlaying(false); } else { // iOS Safari fix: Check for lastSeekedPosition - if (typeof window !== 'undefined' && window.lastSeekedPosition > 0) { + 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() + video + .play() .then(() => { setIsPlaying(true); // Reset lastSeekedPosition after successful play - if (typeof window !== 'undefined') { + if (typeof window !== "undefined") { window.lastSeekedPosition = 0; } }) - .catch(err => { + .catch((err) => { console.error("Error playing video:", err); setIsPlaying(false); // Reset state if play failed }); } }; - + // Toggle mute state const toggleMute = () => { const video = videoRef.current; if (!video) return; - + video.muted = !video.muted; setIsMuted(!isMuted); }; - + // Handle save action const handleSave = () => { // Sort segments chronologically by start time before saving const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); - + // Create the JSON data for saving const saveData = { type: "save", - segments: sortedSegments.map(segment => ({ + segments: sortedSegments.map((segment) => ({ startTime: formatDetailedTime(segment.startTime), endTime: formatDetailedTime(segment.endTime) })) }; - + // Display JSON in alert (for demonstration purposes) - if (process.env.NODE_ENV === 'development') { + if (process.env.NODE_ENV === "development") { console.debug("Saving data:", saveData); } - + // Mark as saved - no unsaved changes setHasUnsavedChanges(false); - + // Debug message - if (process.env.NODE_ENV === 'development') { + if (process.env.NODE_ENV === "development") { console.debug("Changes saved - reset unsaved changes flag"); } - + // Save to history with special "save" action to mark saved state - saveState('save'); - + saveState("save"); + // In a real implementation, this would make a POST request to save the data // logger.debug("Save data:", saveData); }; - + // Handle save a copy action const handleSaveACopy = () => { // Sort segments chronologically by start time before saving const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); - + // Create the JSON data for saving as a copy const saveData = { type: "save_as_a_copy", - segments: sortedSegments.map(segment => ({ + segments: sortedSegments.map((segment) => ({ startTime: formatDetailedTime(segment.startTime), endTime: formatDetailedTime(segment.endTime) })) }; - + // Display JSON in alert (for demonstration purposes) - if (process.env.NODE_ENV === 'development') { + if (process.env.NODE_ENV === "development") { console.debug("Saving data as copy:", saveData); } - + // Mark as saved - no unsaved changes setHasUnsavedChanges(false); - + // Debug message - if (process.env.NODE_ENV === 'development') { + if (process.env.NODE_ENV === "development") { console.debug("Changes saved as copy - reset unsaved changes flag"); } - + // Save to history with special "save_copy" action to mark saved state - saveState('save_copy'); + saveState("save_copy"); }; - + // Handle save segments individually action const handleSaveSegments = () => { // Sort segments chronologically by start time before saving const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); - + // Create the JSON data for saving individual segments const saveData = { type: "save_segments", - segments: sortedSegments.map(segment => ({ + segments: sortedSegments.map((segment) => ({ name: segment.name, startTime: formatDetailedTime(segment.startTime), endTime: formatDetailedTime(segment.endTime) })) }; - + // Display JSON in alert (for demonstration purposes) - if (process.env.NODE_ENV === 'development') { + if (process.env.NODE_ENV === "development") { console.debug("Saving data as segments:", saveData); } - + // Mark as saved - no unsaved changes setHasUnsavedChanges(false); - + // Debug message logger.debug("All segments saved individually - reset unsaved changes flag"); - + // Save to history with special "save_segments" action to mark saved state - saveState('save_segments'); + saveState("save_segments"); }; - + // Handle seeking with mobile check const handleMobileSafeSeek = (time: number) => { // Only allow seeking if not on mobile or if video has been played @@ -777,20 +805,24 @@ const useVideoTrimmer = () => { seekVideo(time); } }; - + // Check if device is mobile - const isMobile = typeof window !== 'undefined' && /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(navigator.userAgent); - + const isMobile = + typeof window !== "undefined" && + /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test( + navigator.userAgent + ); + // Add videoInitialized state const [videoInitialized, setVideoInitialized] = useState(false); - + // Effect to handle segments playback useEffect(() => { if (!isPlayingSegments || !videoRef.current) return; const video = videoRef.current; const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); - + const handleSegmentsPlayback = () => { const currentSegment = orderedSegments[currentSegmentIndex]; if (!currentSegment) return; @@ -810,11 +842,11 @@ const useVideoTrimmer = () => { const nextSegment = orderedSegments[currentSegmentIndex + 1]; video.currentTime = nextSegment.startTime; setCurrentSegmentIndex(currentSegmentIndex + 1); - + // If video is somehow paused, ensure it keeps playing if (video.paused) { logger.debug("Ensuring playback continues to next segment"); - video.play().catch(err => { + video.play().catch((err) => { console.error("Error continuing segment playback:", err); }); } @@ -823,12 +855,12 @@ const useVideoTrimmer = () => { video.pause(); setIsPlayingSegments(false); setCurrentSegmentIndex(0); - video.removeEventListener('timeupdate', handleSegmentsPlayback); + video.removeEventListener("timeupdate", handleSegmentsPlayback); } } }; - video.addEventListener('timeupdate', handleSegmentsPlayback); + video.addEventListener("timeupdate", handleSegmentsPlayback); // Start playing if not already playing if (video.paused && orderedSegments.length > 0) { @@ -837,7 +869,7 @@ const useVideoTrimmer = () => { } return () => { - video.removeEventListener('timeupdate', handleSegmentsPlayback); + video.removeEventListener("timeupdate", handleSegmentsPlayback); }; }, [isPlayingSegments, currentSegmentIndex, clipSegments]); @@ -846,15 +878,20 @@ const useVideoTrimmer = () => { const handleSegmentIndexUpdate = (event: CustomEvent) => { const { segmentIndex } = event.detail; if (isPlayingSegments && segmentIndex !== currentSegmentIndex) { - logger.debug(`Updating current segment index from ${currentSegmentIndex} to ${segmentIndex}`); + logger.debug( + `Updating current segment index from ${currentSegmentIndex} to ${segmentIndex}` + ); setCurrentSegmentIndex(segmentIndex); } }; - document.addEventListener('update-segment-index', handleSegmentIndexUpdate as EventListener); + document.addEventListener("update-segment-index", handleSegmentIndexUpdate as EventListener); return () => { - document.removeEventListener('update-segment-index', handleSegmentIndexUpdate as EventListener); + document.removeEventListener( + "update-segment-index", + handleSegmentIndexUpdate as EventListener + ); }; }, [isPlayingSegments, currentSegmentIndex]); @@ -872,25 +909,25 @@ const useVideoTrimmer = () => { // Start segments playback setIsPlayingSegments(true); setCurrentSegmentIndex(0); - + // Start segments playback - + // Sort segments by start time const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); - + // Start from the first segment video.currentTime = orderedSegments[0].startTime; - + // Start playback with proper error handling - video.play().catch(err => { + video.play().catch((err) => { console.error("Error starting segments playback:", err); setIsPlayingSegments(false); }); - + logger.debug("Starting playback of all segments continuously"); } }; - + return { videoRef, currentTime, @@ -923,7 +960,7 @@ const useVideoTrimmer = () => { handleSaveSegments, isMobile, videoInitialized, - setVideoInitialized, + setVideoInitialized }; }; diff --git a/frontend-tools/video-editor/client/src/index.css b/frontend-tools/video-editor/client/src/index.css index 71eddbc0..e635a6ae 100644 --- a/frontend-tools/video-editor/client/src/index.css +++ b/frontend-tools/video-editor/client/src/index.css @@ -125,13 +125,13 @@ overflow-x: auto; overflow-y: hidden; margin-bottom: 0.75rem; - background-color: #EEE; /* Very light gray background */ + background-color: #eee; /* Very light gray background */ position: relative; } .timeline-container { position: relative; - background-color: #EEE; /* Very light gray background */ + background-color: #eee; /* Very light gray background */ height: 6rem; width: 100%; cursor: pointer; @@ -208,17 +208,27 @@ overflow: hidden; cursor: grab; user-select: none; - transition: box-shadow 0.2s, transform 0.1s; + transition: + box-shadow 0.2s, + transform 0.1s; /* Original z-index for stacking order based on segment ID */ z-index: 15; } /* No background colors for segments, just borders with 2-color scheme */ -.clip-segment:nth-child(odd), .segment-color-1, .segment-color-3, .segment-color-5, .segment-color-7 { +.clip-segment:nth-child(odd), +.segment-color-1, +.segment-color-3, +.segment-color-5, +.segment-color-7 { background-color: transparent; border: 2px solid rgba(0, 123, 255, 0.9); /* Blue border */ } -.clip-segment:nth-child(even), .segment-color-2, .segment-color-4, .segment-color-6, .segment-color-8 { +.clip-segment:nth-child(even), +.segment-color-2, +.segment-color-4, +.segment-color-6, +.segment-color-8 { background-color: transparent; border: 2px solid rgba(108, 117, 125, 0.9); /* Gray border */ } @@ -315,7 +325,7 @@ input[type="range"] { -webkit-appearance: none; height: 6px; - background: #E0E0E0; + background: #e0e0e0; border-radius: 3px; } @@ -350,12 +360,14 @@ input[type="range"]::-webkit-slider-thumb { z-index: 1000; opacity: 0; visibility: hidden; - transition: opacity 0.2s, visibility 0.2s; + transition: + opacity 0.2s, + visibility 0.2s; pointer-events: none; } [data-tooltip]::after { - content: ''; + content: ""; position: absolute; bottom: 100%; left: 50%; @@ -366,7 +378,9 @@ input[type="range"]::-webkit-slider-thumb { margin-bottom: 0px; opacity: 0; visibility: hidden; - transition: opacity 0.2s, visibility 0.2s; + transition: + opacity 0.2s, + visibility 0.2s; pointer-events: none; } @@ -464,7 +478,7 @@ button[disabled][data-tooltip]::after { } .segment-tooltip::after { - content: ''; + content: ""; position: absolute; bottom: -6px; left: 50%; @@ -539,7 +553,7 @@ button[disabled][data-tooltip]::after { } .empty-space-tooltip::after { - content: ''; + content: ""; position: absolute; bottom: -8px; left: 50%; @@ -617,7 +631,9 @@ button[disabled][data-tooltip]::after { } /* Save buttons styling */ -.save-button, .save-copy-button, .save-segments-button { +.save-button, +.save-copy-button, +.save-segments-button { background-color: rgba(0, 123, 255, 0.8); color: white; border: none; @@ -628,7 +644,8 @@ button[disabled][data-tooltip]::after { transition: background-color 0.2s; } -.save-button:hover, .save-copy-button:hover { +.save-button:hover, +.save-copy-button:hover { background-color: rgba(0, 123, 255, 1); } @@ -735,7 +752,8 @@ button[disabled][data-tooltip]::after { font-size: 1.1rem; } -.current-time, .duration-time { +.current-time, +.duration-time { white-space: nowrap; } @@ -770,7 +788,8 @@ button[disabled][data-tooltip]::after { gap: 8px; } - .save-button, .save-copy-button { + .save-button, + .save-copy-button { margin-top: 8px; width: 100%; } diff --git a/frontend-tools/video-editor/client/src/lib/logger.ts b/frontend-tools/video-editor/client/src/lib/logger.ts index 982655c1..f204c26d 100644 --- a/frontend-tools/video-editor/client/src/lib/logger.ts +++ b/frontend-tools/video-editor/client/src/lib/logger.ts @@ -7,25 +7,25 @@ const logger = { * Logs debug messages only in development environment */ debug: (...args: any[]) => { - if (process.env.NODE_ENV === 'development') { + if (process.env.NODE_ENV === "development") { console.debug(...args); } }, - + /** * Always logs error messages */ error: (...args: any[]) => console.error(...args), - + /** * Always logs warning messages */ warn: (...args: any[]) => console.warn(...args), - + /** * Always logs info messages */ info: (...args: any[]) => console.info(...args) }; -export default logger; \ No newline at end of file +export default logger; diff --git a/frontend-tools/video-editor/client/src/lib/queryClient.ts b/frontend-tools/video-editor/client/src/lib/queryClient.ts index a8b3fc1d..892f099a 100644 --- a/frontend-tools/video-editor/client/src/lib/queryClient.ts +++ b/frontend-tools/video-editor/client/src/lib/queryClient.ts @@ -10,13 +10,13 @@ async function throwIfResNotOk(res: Response) { export async function apiRequest( method: string, url: string, - data?: unknown | undefined, + data?: unknown | undefined ): Promise { const res = await fetch(url, { method, headers: data ? { "Content-Type": "application/json" } : {}, body: data ? JSON.stringify(data) : undefined, - credentials: "include", + credentials: "include" }); await throwIfResNotOk(res); @@ -24,13 +24,11 @@ export async function apiRequest( } type UnauthorizedBehavior = "returnNull" | "throw"; -export const getQueryFn: (options: { - on401: UnauthorizedBehavior; -}) => QueryFunction = +export const getQueryFn: (options: { on401: UnauthorizedBehavior }) => QueryFunction = ({ on401: unauthorizedBehavior }) => async ({ queryKey }) => { const res = await fetch(queryKey[0] as string, { - credentials: "include", + credentials: "include" }); if (unauthorizedBehavior === "returnNull" && res.status === 401) { @@ -48,10 +46,10 @@ export const queryClient = new QueryClient({ refetchInterval: false, refetchOnWindowFocus: false, staleTime: Infinity, - retry: false, + retry: false }, mutations: { - retry: false, - }, - }, + retry: false + } + } }); diff --git a/frontend-tools/video-editor/client/src/lib/timeUtils.ts b/frontend-tools/video-editor/client/src/lib/timeUtils.ts index d33862a9..14fef1ba 100644 --- a/frontend-tools/video-editor/client/src/lib/timeUtils.ts +++ b/frontend-tools/video-editor/client/src/lib/timeUtils.ts @@ -3,17 +3,17 @@ */ export const formatDetailedTime = (seconds: number): string => { if (isNaN(seconds)) return "00:00:00.000"; - + const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const remainingSeconds = Math.floor(seconds % 60); const milliseconds = Math.round((seconds % 1) * 1000); - + const formattedHours = String(hours).padStart(2, "0"); const formattedMinutes = String(minutes).padStart(2, "0"); const formattedSeconds = String(remainingSeconds).padStart(2, "0"); const formattedMilliseconds = String(milliseconds).padStart(3, "0"); - + return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}`; }; diff --git a/frontend-tools/video-editor/client/src/lib/utils.ts b/frontend-tools/video-editor/client/src/lib/utils.ts index bd0c391d..a5ef1935 100644 --- a/frontend-tools/video-editor/client/src/lib/utils.ts +++ b/frontend-tools/video-editor/client/src/lib/utils.ts @@ -1,6 +1,6 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } diff --git a/frontend-tools/video-editor/client/src/lib/videoUtils.ts b/frontend-tools/video-editor/client/src/lib/videoUtils.ts index affb9d18..0586e031 100644 --- a/frontend-tools/video-editor/client/src/lib/videoUtils.ts +++ b/frontend-tools/video-editor/client/src/lib/videoUtils.ts @@ -2,20 +2,17 @@ * Generate a solid color background for a segment * Returns a CSS color based on the segment position */ -export const generateSolidColor = ( - time: number, - duration: number -): string => { +export const generateSolidColor = (time: number, duration: number): string => { // Use the time position to create different colors // This gives each segment a different color without needing an image const position = Math.min(Math.max(time / (duration || 1), 0), 1); - + // Calculate color based on position // Use an extremely light blue-based color palette const hue = 210; // Blue base const saturation = 40 + Math.floor(position * 20); // 40-60% (less saturated) const lightness = 85 + Math.floor(position * 8); // 85-93% (extremely light) - + return `hsl(${hue}, ${saturation}%, ${lightness}%)`; }; @@ -24,27 +21,27 @@ export const generateSolidColor = ( * Now returns a data URL for a solid color square instead of a video thumbnail */ export const generateThumbnail = async ( - videoElement: HTMLVideoElement, + videoElement: HTMLVideoElement, time: number ): Promise => { return new Promise((resolve) => { // Create a small canvas for the solid color - const canvas = document.createElement('canvas'); + const canvas = document.createElement("canvas"); canvas.width = 10; // Much smaller - we only need a color canvas.height = 10; - - const ctx = canvas.getContext('2d'); + + const ctx = canvas.getContext("2d"); if (ctx) { // Get the solid color based on time const color = generateSolidColor(time, videoElement.duration); - + // Fill with solid color ctx.fillStyle = color; ctx.fillRect(0, 0, canvas.width, canvas.height); } - + // Convert to data URL (much smaller now) - const dataUrl = canvas.toDataURL('image/png', 0.5); + const dataUrl = canvas.toDataURL("image/png", 0.5); resolve(dataUrl); }); }; diff --git a/frontend-tools/video-editor/client/src/main.tsx b/frontend-tools/video-editor/client/src/main.tsx index 780c763a..044e1cd2 100644 --- a/frontend-tools/video-editor/client/src/main.tsx +++ b/frontend-tools/video-editor/client/src/main.tsx @@ -2,7 +2,7 @@ import { createRoot } from "react-dom/client"; import App from "./App"; import "./index.css"; -if (typeof window !== 'undefined') { +if (typeof window !== "undefined") { window.MEDIA_DATA = { videoUrl: "", mediaId: "" @@ -30,8 +30,8 @@ const mountComponents = () => { } }; -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', mountComponents); +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", mountComponents); } else { mountComponents(); -} \ No newline at end of file +} diff --git a/frontend-tools/video-editor/client/src/services/videoApi.ts b/frontend-tools/video-editor/client/src/services/videoApi.ts index 7e3e5175..88389907 100644 --- a/frontend-tools/video-editor/client/src/services/videoApi.ts +++ b/frontend-tools/video-editor/client/src/services/videoApi.ts @@ -4,36 +4,36 @@ interface TrimVideoRequest { segments: { startTime: string; endTime: string; - name?: string; + name?: string; }[]; saveAsCopy?: boolean; - saveIndividualSegments?: boolean; + saveIndividualSegments?: boolean; } interface TrimVideoResponse { msg: string; url_redirect: string; - status?: number; // HTTP status code for success/error - error?: string; // Error message if status is not 200 + status?: number; // HTTP status code for success/error + error?: string; // Error message if status is not 200 } // Helper function to simulate delay -const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // For now, we'll use a mock API that returns a promise // This can be replaced with actual API calls later export const trimVideo = async ( - mediaId: string, + mediaId: string, data: TrimVideoRequest ): Promise => { try { // Attempt the real API call const response = await fetch(`/api/v1/media/${mediaId}/trim_video`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }); - + if (!response.ok) { // For error responses, return with error status and message if (response.status === 400) { @@ -86,7 +86,7 @@ export const trimVideo = async ( }; } } - + // Successful response const jsonResponse = await response.json(); return { @@ -104,7 +104,7 @@ export const trimVideo = async ( url_redirect: `./view?m=${mediaId}` }; } - + /* Mock implementation that simulates network latency return new Promise((resolve) => { setTimeout(() => { @@ -115,4 +115,4 @@ export const trimVideo = async ( }, 1500); // Simulate 1.5 second server delay }); */ -}; \ No newline at end of file +}; diff --git a/frontend-tools/video-editor/client/src/styles/ClipSegments.css b/frontend-tools/video-editor/client/src/styles/ClipSegments.css index 49d55474..3b71925e 100644 --- a/frontend-tools/video-editor/client/src/styles/ClipSegments.css +++ b/frontend-tools/video-editor/client/src/styles/ClipSegments.css @@ -4,7 +4,7 @@ [data-tooltip] { position: relative; } - + [data-tooltip]:before { content: attr(data-tooltip); position: absolute; @@ -21,13 +21,15 @@ white-space: nowrap; opacity: 0; visibility: hidden; - transition: opacity 0.2s, visibility 0.2s; + transition: + opacity 0.2s, + visibility 0.2s; z-index: 1000; pointer-events: none; } - + [data-tooltip]:after { - content: ''; + content: ""; position: absolute; bottom: 100%; left: 50%; @@ -37,17 +39,19 @@ border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent; opacity: 0; visibility: hidden; - transition: opacity 0.2s, visibility 0.2s; + transition: + opacity 0.2s, + visibility 0.2s; pointer-events: none; } - + [data-tooltip]:hover:before, [data-tooltip]:hover:after { opacity: 1; visibility: visible; } } - + /* Hide button tooltips on touch devices */ @media (pointer: coarse) { [data-tooltip]:before, @@ -143,7 +147,9 @@ border-radius: 9999px; border: none; cursor: pointer; - transition: background-color 0.2s, color 0.2s; + transition: + background-color 0.2s, + color 0.2s; min-width: auto; &:hover { @@ -163,12 +169,28 @@ color: rgba(51, 51, 51, 0.7); } - .segment-color-1 { background-color: rgba(59, 130, 246, 0.15); } - .segment-color-2 { background-color: rgba(16, 185, 129, 0.15); } - .segment-color-3 { background-color: rgba(245, 158, 11, 0.15); } - .segment-color-4 { background-color: rgba(239, 68, 68, 0.15); } - .segment-color-5 { background-color: rgba(139, 92, 246, 0.15); } - .segment-color-6 { background-color: rgba(236, 72, 153, 0.15); } - .segment-color-7 { background-color: rgba(6, 182, 212, 0.15); } - .segment-color-8 { background-color: rgba(250, 204, 21, 0.15); } -} \ No newline at end of file + .segment-color-1 { + background-color: rgba(59, 130, 246, 0.15); + } + .segment-color-2 { + background-color: rgba(16, 185, 129, 0.15); + } + .segment-color-3 { + background-color: rgba(245, 158, 11, 0.15); + } + .segment-color-4 { + background-color: rgba(239, 68, 68, 0.15); + } + .segment-color-5 { + background-color: rgba(139, 92, 246, 0.15); + } + .segment-color-6 { + background-color: rgba(236, 72, 153, 0.15); + } + .segment-color-7 { + background-color: rgba(6, 182, 212, 0.15); + } + .segment-color-8 { + background-color: rgba(250, 204, 21, 0.15); + } +} diff --git a/frontend-tools/video-editor/client/src/styles/EditingTools.css b/frontend-tools/video-editor/client/src/styles/EditingTools.css index 93768d31..06f611bd 100644 --- a/frontend-tools/video-editor/client/src/styles/EditingTools.css +++ b/frontend-tools/video-editor/client/src/styles/EditingTools.css @@ -21,13 +21,15 @@ white-space: nowrap; opacity: 0; visibility: hidden; - transition: opacity 0.2s, visibility 0.2s; + transition: + opacity 0.2s, + visibility 0.2s; z-index: 1000; pointer-events: none; } [data-tooltip]:after { - content: ''; + content: ""; position: absolute; bottom: 100%; left: 50%; @@ -37,7 +39,9 @@ border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent; opacity: 0; visibility: hidden; - transition: opacity 0.2s, visibility 0.2s; + transition: + opacity 0.2s, + visibility 0.2s; pointer-events: none; } diff --git a/frontend-tools/video-editor/client/src/styles/IOSNotification.css b/frontend-tools/video-editor/client/src/styles/IOSNotification.css index 3a0c9a96..5e3af434 100644 --- a/frontend-tools/video-editor/client/src/styles/IOSNotification.css +++ b/frontend-tools/video-editor/client/src/styles/IOSNotification.css @@ -132,7 +132,7 @@ .ios-notification { padding-top: env(safe-area-inset-top); } - + .ios-notification-close { padding: 10px; } @@ -143,11 +143,11 @@ .ios-notification-content { padding: 5px; } - + .ios-notification-message h3 { font-size: 15px; } - + .ios-notification-message p, .ios-notification-message ol { font-size: 13px; @@ -164,4 +164,4 @@ html.ios-device { 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 index 438cfd4e..9fa7d707 100644 --- a/frontend-tools/video-editor/client/src/styles/IOSPlayPrompt.css +++ b/frontend-tools/video-editor/client/src/styles/IOSPlayPrompt.css @@ -93,4 +93,4 @@ /* 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 index 3b671b34..8d8dbf92 100644 --- a/frontend-tools/video-editor/client/src/styles/IOSVideoPlayer.css +++ b/frontend-tools/video-editor/client/src/styles/IOSVideoPlayer.css @@ -36,13 +36,13 @@ .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; @@ -76,19 +76,19 @@ /* 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 */ + -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-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 f5d51349..0d67c342 100644 --- a/frontend-tools/video-editor/client/src/styles/Modal.css +++ b/frontend-tools/video-editor/client/src/styles/Modal.css @@ -1,302 +1,306 @@ #video-editor-trim-root { -.modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; -} - -.modal-container { - background-color: white; - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - width: 90%; - max-width: 500px; - max-height: 90vh; - overflow-y: auto; - animation: modal-fade-in 0.3s ease-out; -} - -@keyframes modal-fade-in { - from { - opacity: 0; - transform: translateY(-20px); + .modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; } - to { - opacity: 1; - transform: translateY(0); - } -} -.modal-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px 20px; - border-bottom: 1px solid #eee; -} - -.modal-title { - margin: 0; - font-size: 1.25rem; - font-weight: 600; - color: #333; -} - -.modal-close-button { - background: none; - border: none; - cursor: pointer; - color: #666; - padding: 4px; - display: flex; - align-items: center; - justify-content: center; - transition: color 0.2s; -} - -.modal-close-button:hover { - color: #000; -} - -.modal-content { - padding: 20px; - color: #333; - font-size: 1rem; - line-height: 1.5; - max-height: 400px; - overflow-y: auto; -} - -.modal-actions { - display: flex; - justify-content: flex-end; - padding: 16px 20px; - border-top: 1px solid #eee; - gap: 12px; -} - -.modal-button { - padding: 8px 16px; - border-radius: 4px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - border: none; -} - -.modal-button-primary { - background-color: #0066cc; - color: white; -} - -.modal-button-primary:hover { - background-color: #0055aa; -} - -.modal-button-secondary { - background-color: #f0f0f0; - color: #333; -} - -.modal-button-secondary:hover { - background-color: #e0e0e0; -} - -.modal-button-danger { - background-color: #dc3545; - color: white; -} - -.modal-button-danger:hover { - background-color: #bd2130; -} - -/* Modal content styles */ -.modal-message { - margin-bottom: 16px; - font-size: 1rem; -} - -.text-center { - text-align: center; -} - -.modal-spinner { - display: flex; - align-items: center; - justify-content: center; - margin: 20px 0; -} - -.spinner { - border: 4px solid rgba(0, 0, 0, 0.1); - border-radius: 50%; - border-top: 4px solid #0066cc; - width: 30px; - height: 30px; - animation: spin 1s linear infinite; -} - -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -.modal-success-icon { - display: flex; - justify-content: center; - margin-bottom: 16px; - color: #28a745; - 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; - margin-bottom: 16px; - color: #dc3545; - 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; - gap: 10px; - margin-top: 20px; -} - -.modal-choice-button { - padding: 12px 16px; - border: none; - border-radius: 4px; - background-color: #0066cc; - text-align: center; - cursor: pointer; - transition: all 0.2s; - display: flex; - align-items: center; - justify-content: center; - font-weight: 500; - text-decoration: none; - color: white; -} - -.modal-choice-button:hover { - 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; - min-width: 220px; - background-color: #0066cc; - color: white; -} - -.centered-choice:hover { - background-color: #0055aa; -} - -@media (max-width: 480px) { .modal-container { - width: 95%; + background-color: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + width: 90%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; + animation: modal-fade-in 0.3s ease-out; } - + + @keyframes modal-fade-in { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid #eee; + } + + .modal-title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: #333; + } + + .modal-close-button { + background: none; + border: none; + cursor: pointer; + color: #666; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.2s; + } + + .modal-close-button:hover { + color: #000; + } + + .modal-content { + padding: 20px; + color: #333; + font-size: 1rem; + line-height: 1.5; + max-height: 400px; + overflow-y: auto; + } + .modal-actions { - flex-direction: column; + display: flex; + justify-content: flex-end; + padding: 16px 20px; + border-top: 1px solid #eee; + gap: 12px; } - + .modal-button { - width: 100%; + padding: 8px 16px; + border-radius: 4px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + border: none; + } + + .modal-button-primary { + background-color: #0066cc; + color: white; + } + + .modal-button-primary:hover { + background-color: #0055aa; + } + + .modal-button-secondary { + background-color: #f0f0f0; + color: #333; + } + + .modal-button-secondary:hover { + background-color: #e0e0e0; + } + + .modal-button-danger { + background-color: #dc3545; + color: white; + } + + .modal-button-danger:hover { + background-color: #bd2130; + } + + /* Modal content styles */ + .modal-message { + margin-bottom: 16px; + font-size: 1rem; + } + + .text-center { + text-align: center; + } + + .modal-spinner { + display: flex; + align-items: center; + justify-content: center; + margin: 20px 0; + } + + .spinner { + border: 4px solid rgba(0, 0, 0, 0.1); + border-radius: 50%; + border-top: 4px solid #0066cc; + width: 30px; + height: 30px; + animation: spin 1s linear infinite; + } + + @keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + + .modal-success-icon { + display: flex; + justify-content: center; + margin-bottom: 16px; + color: #28a745; + 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; + margin-bottom: 16px; + color: #dc3545; + 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; + gap: 10px; + margin-top: 20px; + } + + .modal-choice-button { + padding: 12px 16px; + border: none; + border-radius: 4px; + background-color: #0066cc; + text-align: center; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + font-weight: 500; + text-decoration: none; + color: white; + } + + .modal-choice-button:hover { + 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; + min-width: 220px; + background-color: #0066cc; + color: white; + } + + .centered-choice:hover { + background-color: #0055aa; + } + + @media (max-width: 480px) { + .modal-container { + width: 95%; + } + + .modal-actions { + flex-direction: column; + } + + .modal-button { + 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; } } - -.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 2467b627..aa990a64 100644 --- a/frontend-tools/video-editor/client/src/styles/TimelineControls.css +++ b/frontend-tools/video-editor/client/src/styles/TimelineControls.css @@ -257,7 +257,7 @@ } .clip-segment-handle:after { - content: ''; + content: ""; position: absolute; top: 50%; left: 50%; @@ -321,7 +321,7 @@ .segment-tooltip:after, .empty-space-tooltip:after { - content: ''; + content: ""; position: absolute; bottom: -5px; left: 50%; @@ -335,7 +335,7 @@ .segment-tooltip:before, .empty-space-tooltip:before { - content: ''; + content: ""; position: absolute; bottom: -6px; left: 50%; @@ -612,13 +612,15 @@ white-space: nowrap; opacity: 0; visibility: hidden; - transition: opacity 0.2s, visibility 0.2s; + transition: + opacity 0.2s, + visibility 0.2s; z-index: 1000; pointer-events: none; } [data-tooltip]:after { - content: ''; + content: ""; position: absolute; bottom: 100%; left: 50%; @@ -628,7 +630,9 @@ border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent; opacity: 0; visibility: hidden; - transition: opacity 0.2s, visibility 0.2s; + transition: + opacity 0.2s, + visibility 0.2s; pointer-events: none; } diff --git a/frontend-tools/video-editor/client/src/styles/TwoRowTooltip.css b/frontend-tools/video-editor/client/src/styles/TwoRowTooltip.css index d8c7b542..9d70ee92 100644 --- a/frontend-tools/video-editor/client/src/styles/TwoRowTooltip.css +++ b/frontend-tools/video-editor/client/src/styles/TwoRowTooltip.css @@ -111,7 +111,9 @@ white-space: nowrap; opacity: 0; visibility: hidden; - transition: opacity 0.2s, visibility 0.2s; + transition: + opacity 0.2s, + visibility 0.2s; z-index: 2500; /* High z-index */ pointer-events: none; } @@ -130,7 +132,9 @@ margin-left: 0; /* Reset margin */ opacity: 0; visibility: hidden; - transition: opacity 0.2s, visibility 0.2s; + transition: + opacity 0.2s, + visibility 0.2s; z-index: 2500; /* High z-index */ pointer-events: none; } diff --git a/frontend-tools/video-editor/client/src/styles/VideoPlayer.css b/frontend-tools/video-editor/client/src/styles/VideoPlayer.css index bbe84d67..11c7ed82 100644 --- a/frontend-tools/video-editor/client/src/styles/VideoPlayer.css +++ b/frontend-tools/video-editor/client/src/styles/VideoPlayer.css @@ -4,7 +4,7 @@ [data-tooltip] { position: relative; } - + [data-tooltip]:before { content: attr(data-tooltip); position: absolute; @@ -21,13 +21,15 @@ white-space: nowrap; opacity: 0; visibility: hidden; - transition: opacity 0.2s, visibility 0.2s; + transition: + opacity 0.2s, + visibility 0.2s; z-index: 1000; pointer-events: none; } - + [data-tooltip]:after { - content: ''; + content: ""; position: absolute; bottom: 100%; left: 50%; @@ -37,17 +39,19 @@ border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent; opacity: 0; visibility: hidden; - transition: opacity 0.2s, visibility 0.2s; + transition: + opacity 0.2s, + visibility 0.2s; pointer-events: none; } - + [data-tooltip]:hover:before, [data-tooltip]:hover:after { opacity: 1; visibility: visible; } } - + /* Hide button tooltips on touch devices */ @media (pointer: coarse) { [data-tooltip]:before, @@ -71,7 +75,7 @@ -webkit-user-select: none; user-select: none; } - + .video-player-container video { width: 100%; height: 100%; @@ -83,7 +87,7 @@ -webkit-user-select: none; user-select: none; } - + /* iOS-specific styles */ @supports (-webkit-touch-callout: none) { .video-player-container video { @@ -92,7 +96,7 @@ -webkit-touch-callout: none; } } - + .play-pause-indicator { position: absolute; top: 50%; @@ -106,19 +110,19 @@ transition: opacity 0.3s; pointer-events: none; } - + .video-player-container:hover .play-pause-indicator { opacity: 1; } - + .play-pause-indicator::before { - content: ''; + content: ""; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } - + .play-pause-indicator.play-icon::before { width: 0; height: 0; @@ -127,14 +131,14 @@ border-left: 25px solid white; margin-left: 3px; } - + .play-pause-indicator.pause-icon::before { width: 20px; height: 25px; border-left: 6px solid white; border-right: 6px solid white; } - + /* iOS First-play indicator */ .ios-first-play-indicator { position: absolute; @@ -148,7 +152,7 @@ justify-content: center; z-index: 10; } - + .ios-play-message { color: white; font-size: 1.2rem; @@ -158,13 +162,22 @@ border-radius: 0.5rem; animation: pulse 2s infinite; } - + @keyframes pulse { - 0% { opacity: 0.7; transform: scale(1); } - 50% { opacity: 1; transform: scale(1.05); } - 100% { opacity: 0.7; transform: scale(1); } + 0% { + opacity: 0.7; + transform: scale(1); + } + 50% { + opacity: 1; + transform: scale(1.05); + } + 100% { + opacity: 0.7; + transform: scale(1); + } } - + .video-controls { position: absolute; bottom: 0; @@ -175,21 +188,21 @@ opacity: 0; transition: opacity 0.3s; } - + .video-player-container:hover .video-controls { opacity: 1; } - + .video-current-time { color: white; font-size: 0.875rem; } - + .video-duration { color: white; font-size: 0.875rem; } - + .video-time-display { display: flex; justify-content: space-between; @@ -197,7 +210,7 @@ color: white; font-size: 0.875rem; } - + .video-progress { position: relative; height: 6px; @@ -208,11 +221,11 @@ touch-action: none; /* Prevent browser handling of drag gestures */ flex-grow: 1; } - + .video-progress.dragging { height: 8px; } - + .video-progress-fill { position: absolute; top: 0; @@ -222,7 +235,7 @@ border-radius: 3px; pointer-events: none; } - + .video-scrubber { position: absolute; top: 50%; @@ -232,9 +245,12 @@ background-color: #ff0000; border-radius: 50%; cursor: grab; - transition: transform 0.1s ease, width 0.1s ease, height 0.1s ease; + 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); @@ -243,22 +259,22 @@ 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: ''; + content: ""; position: absolute; top: -10px; left: -10px; @@ -266,14 +282,14 @@ bottom: -10px; } } - + .video-controls-buttons { display: flex; align-items: center; justify-content: flex-end; gap: 0.75rem; } - + .mute-button, .fullscreen-button { min-width: auto; @@ -283,17 +299,17 @@ cursor: pointer; padding: 0.25rem; transition: transform 0.2s; - + &:hover { transform: scale(1.1); } - + svg { width: 1.25rem; height: 1.25rem; } } - + /* Time tooltip that appears when dragging */ .video-time-tooltip { position: absolute; @@ -309,10 +325,10 @@ 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: ''; + content: ""; position: absolute; bottom: -4px; left: 50%; @@ -323,4 +339,4 @@ border-right: 4px solid transparent; border-top: 4px solid rgba(0, 0, 0, 0.7); } -} \ No newline at end of file +}