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 */}
-
-15s
-
+15s
-
+
{/* iOS Fine Control Buttons */}
-
-50ms
-
-
+
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 && (
-
{isMuted ? (
-
+
@@ -404,23 +412,35 @@ const VideoPlayer: React.FC = ({
) : (
-
+
)}
)}
-
+
{/* 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
+}