mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-11-06 07:28:53 -05:00
fixes (#56) - 2894250364
https://github.com/mediacms-io/mediacms-deic/issues/56#issuecomment-2894250364
This commit is contained in:
parent
0c75e2503c
commit
347ce9af6f
1
frontend-tools/video-editor/.gitignore
vendored
1
frontend-tools/video-editor/.gitignore
vendored
@ -5,3 +5,4 @@ server/public
|
||||
vite.config.ts.*
|
||||
*.tar.gz
|
||||
yt.readme.md
|
||||
client/public/videos/sample-video.mp4
|
||||
|
||||
Binary file not shown.
@ -1,4 +1,6 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { formatTime, formatDetailedTime } from "./lib/timeUtils";
|
||||
import logger from "./lib/logger";
|
||||
import VideoPlayer from "@/components/VideoPlayer";
|
||||
import TimelineControls from "@/components/TimelineControls";
|
||||
import EditingTools from "@/components/EditingTools";
|
||||
@ -7,85 +9,48 @@ import MobilePlayPrompt from "@/components/IOSPlayPrompt";
|
||||
import useVideoTrimmer from "@/hooks/useVideoTrimmer";
|
||||
|
||||
const App = () => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [videoInitialized, setVideoInitialized] = useState(false);
|
||||
|
||||
const {
|
||||
videoRef,
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
isPreviewMode,
|
||||
setIsPlaying,
|
||||
isMuted,
|
||||
isPreviewMode,
|
||||
thumbnails,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
splitPoints,
|
||||
zoomLevel,
|
||||
clipSegments,
|
||||
hasUnsavedChanges,
|
||||
historyPosition,
|
||||
history,
|
||||
hasUnsavedChanges,
|
||||
playPauseVideo,
|
||||
seekVideo,
|
||||
handleTrimStartChange,
|
||||
handleTrimEndChange,
|
||||
handleZoomChange,
|
||||
handleMobileSafeSeek,
|
||||
handleSplit,
|
||||
handleReset,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
handlePreview,
|
||||
handlePlay,
|
||||
handleZoomChange,
|
||||
toggleMute,
|
||||
handleSave,
|
||||
handleSaveACopy,
|
||||
handleSaveSegments,
|
||||
isMobile,
|
||||
videoInitialized,
|
||||
setVideoInitialized,
|
||||
} = useVideoTrimmer();
|
||||
|
||||
// Refs for hold-to-continue functionality
|
||||
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const decrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Detect if we're on a mobile device and reset on each visit
|
||||
useEffect(() => {
|
||||
const checkIsMobile = () => {
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(navigator.userAgent);
|
||||
};
|
||||
|
||||
setIsMobile(checkIsMobile());
|
||||
setVideoInitialized(false); // Reset each time for mobile devices
|
||||
|
||||
// Add an event listener to detect when the video has been played
|
||||
const video = videoRef.current;
|
||||
if (video) {
|
||||
const handlePlay = () => {
|
||||
setVideoInitialized(true);
|
||||
};
|
||||
|
||||
video.addEventListener('play', handlePlay);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('play', handlePlay);
|
||||
};
|
||||
}
|
||||
}, [videoRef]);
|
||||
|
||||
// Clean up intervals on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
|
||||
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Function to play from the beginning
|
||||
const playFromBeginning = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = 0;
|
||||
seekVideo(0);
|
||||
handleMobileSafeSeek(0);
|
||||
if (!isPlaying) {
|
||||
playPauseVideo();
|
||||
handlePlay();
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -93,80 +58,176 @@ const App = () => {
|
||||
// Function to jump 15 seconds backward
|
||||
const jumpBackward15 = () => {
|
||||
const newTime = Math.max(0, currentTime - 15);
|
||||
seekVideo(newTime);
|
||||
handleMobileSafeSeek(newTime);
|
||||
};
|
||||
|
||||
// Function to jump 15 seconds forward
|
||||
const jumpForward15 = () => {
|
||||
const newTime = Math.min(duration, currentTime + 15);
|
||||
seekVideo(newTime);
|
||||
handleMobileSafeSeek(newTime);
|
||||
};
|
||||
|
||||
// Start continuous 50ms increment when button is held
|
||||
const startIncrement = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
// Prevent default to avoid text selection
|
||||
e.preventDefault();
|
||||
const handlePlay = () => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
|
||||
const video = videoRef.current;
|
||||
|
||||
// First immediate adjustment
|
||||
seekVideo(Math.min(duration, currentTime + 0.05));
|
||||
|
||||
// Setup continuous adjustment
|
||||
incrementIntervalRef.current = setInterval(() => {
|
||||
const currentVideoTime = videoRef.current?.currentTime || 0;
|
||||
const newTime = Math.min(duration, currentVideoTime + 0.05);
|
||||
seekVideo(newTime);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Stop continuous increment
|
||||
const stopIncrement = () => {
|
||||
if (incrementIntervalRef.current) {
|
||||
clearInterval(incrementIntervalRef.current);
|
||||
incrementIntervalRef.current = null;
|
||||
// If already playing, just pause the video
|
||||
if (isPlaying) {
|
||||
video.pause();
|
||||
setIsPlaying(false);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Start continuous 50ms decrement when button is held
|
||||
const startDecrement = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
// Prevent default to avoid text selection
|
||||
e.preventDefault();
|
||||
|
||||
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
|
||||
const currentPosition = Number(video.currentTime.toFixed(6)); // Fix to microsecond precision
|
||||
|
||||
// First immediate adjustment
|
||||
seekVideo(Math.max(0, currentTime - 0.05));
|
||||
// Find the next stopping point based on current position
|
||||
let stopTime = duration;
|
||||
let currentSegment = null;
|
||||
let nextSegment = null;
|
||||
|
||||
// Setup continuous adjustment
|
||||
decrementIntervalRef.current = setInterval(() => {
|
||||
const currentVideoTime = videoRef.current?.currentTime || 0;
|
||||
const newTime = Math.max(0, currentVideoTime - 0.05);
|
||||
seekVideo(newTime);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Stop continuous decrement
|
||||
const stopDecrement = () => {
|
||||
if (decrementIntervalRef.current) {
|
||||
clearInterval(decrementIntervalRef.current);
|
||||
decrementIntervalRef.current = null;
|
||||
// Sort segments by start time to ensure correct order
|
||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// First, check if we're inside a segment or exactly at its start/end
|
||||
currentSegment = sortedSegments.find(seg => {
|
||||
const segStartTime = Number(seg.startTime.toFixed(6));
|
||||
const segEndTime = Number(seg.endTime.toFixed(6));
|
||||
|
||||
// Check if we're inside the segment
|
||||
if (currentPosition > segStartTime && currentPosition < segEndTime) {
|
||||
return true;
|
||||
}
|
||||
// Check if we're exactly at the start
|
||||
if (currentPosition === segStartTime) {
|
||||
return true;
|
||||
}
|
||||
// Check if we're exactly at the end
|
||||
if (currentPosition === segEndTime) {
|
||||
// If we're at the end of a segment, we should look for the next one
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// If we're not in a segment, find the next segment
|
||||
if (!currentSegment) {
|
||||
nextSegment = sortedSegments.find(seg => {
|
||||
const segStartTime = Number(seg.startTime.toFixed(6));
|
||||
return segStartTime > currentPosition;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle seeking with mobile check
|
||||
const handleMobileSafeSeek = (time: number) => {
|
||||
// Only allow seeking if not on mobile or if video has been played
|
||||
if (!isMobile || videoInitialized) {
|
||||
seekVideo(time);
|
||||
|
||||
// Determine where to stop based on position
|
||||
if (currentSegment) {
|
||||
// If we're in a segment, stop at its end
|
||||
stopTime = Number(currentSegment.endTime.toFixed(6));
|
||||
} else if (nextSegment) {
|
||||
// If we're in a cutaway and there's a next segment, stop at its start
|
||||
stopTime = Number(nextSegment.startTime.toFixed(6));
|
||||
}
|
||||
|
||||
// Create a boundary checker function with high precision
|
||||
const checkBoundary = () => {
|
||||
if (!video) return;
|
||||
|
||||
const currentPosition = Number(video.currentTime.toFixed(6));
|
||||
const timeLeft = Number((stopTime - currentPosition).toFixed(6));
|
||||
|
||||
// If we've reached or passed the boundary
|
||||
if (timeLeft <= 0 || currentPosition >= stopTime) {
|
||||
// First pause playback
|
||||
video.pause();
|
||||
|
||||
// Force exact position with multiple verification attempts
|
||||
const setExactPosition = () => {
|
||||
if (!video) return;
|
||||
|
||||
// Set to exact boundary time
|
||||
video.currentTime = stopTime;
|
||||
handleMobileSafeSeek(stopTime);
|
||||
|
||||
const actualPosition = Number(video.currentTime.toFixed(6));
|
||||
const difference = Number(Math.abs(actualPosition - stopTime).toFixed(6));
|
||||
|
||||
logger.debug("Position verification:", {
|
||||
target: formatDetailedTime(stopTime),
|
||||
actual: formatDetailedTime(actualPosition),
|
||||
difference: difference
|
||||
});
|
||||
|
||||
// If we're not exactly at the target position, try one more time
|
||||
if (difference > 0) {
|
||||
video.currentTime = stopTime;
|
||||
handleMobileSafeSeek(stopTime);
|
||||
}
|
||||
};
|
||||
|
||||
// Multiple attempts to ensure precision, with increasing delays
|
||||
setExactPosition();
|
||||
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);
|
||||
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
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Start our boundary checker
|
||||
video.addEventListener('timeupdate', checkBoundary);
|
||||
|
||||
// Start playing
|
||||
video.play()
|
||||
.then(() => {
|
||||
setIsPlaying(true);
|
||||
setVideoInitialized(true);
|
||||
logger.debug("Playback started:", {
|
||||
from: formatDetailedTime(currentPosition),
|
||||
to: formatDetailedTime(stopTime),
|
||||
currentSegment: currentSegment ? {
|
||||
id: currentSegment.id,
|
||||
start: formatDetailedTime(currentSegment.startTime),
|
||||
end: formatDetailedTime(currentSegment.endTime)
|
||||
} : 'None',
|
||||
nextSegment: nextSegment ? {
|
||||
id: nextSegment.id,
|
||||
start: formatDetailedTime(nextSegment.startTime),
|
||||
end: formatDetailedTime(nextSegment.endTime)
|
||||
} : 'None'
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error playing video:", err);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background min-h-screen">
|
||||
<MobilePlayPrompt
|
||||
videoRef={videoRef}
|
||||
onPlay={playPauseVideo}
|
||||
onPlay={handlePlay}
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-6 max-w-6xl">
|
||||
@ -177,7 +238,7 @@ const App = () => {
|
||||
duration={duration}
|
||||
isPlaying={isPlaying}
|
||||
isMuted={isMuted}
|
||||
onPlayPause={playPauseVideo}
|
||||
onPlayPause={handlePlay}
|
||||
onSeek={handleMobileSafeSeek}
|
||||
onToggleMute={toggleMute}
|
||||
/>
|
||||
@ -217,6 +278,9 @@ const App = () => {
|
||||
isPreviewMode={isPreviewMode}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
isIOSUninitialized={isMobile && !videoInitialized}
|
||||
isPlaying={isPlaying}
|
||||
setIsPlaying={setIsPlaying}
|
||||
onPlayPause={handlePlay}
|
||||
/>
|
||||
|
||||
{/* Clip Segments */}
|
||||
|
||||
@ -47,7 +47,7 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
|
||||
return (
|
||||
<div className="mobile-play-prompt-overlay">
|
||||
<div className="mobile-play-prompt">
|
||||
<h3>Mobile Device Notice</h3>
|
||||
{/* <h3>Mobile Device Notice</h3>
|
||||
|
||||
<p>
|
||||
For the best video editing experience on mobile devices, you need to <strong>play the video first</strong> before
|
||||
@ -61,13 +61,13 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
|
||||
<li>After the video starts, you can pause it</li>
|
||||
<li>Then you'll be able to use all timeline controls</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<button
|
||||
className="mobile-play-button"
|
||||
onClick={handlePlayClick}
|
||||
>
|
||||
Play Video Now
|
||||
Click to start editing...
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -37,7 +37,7 @@ const IOSVideoPlayer = ({
|
||||
}
|
||||
} else {
|
||||
// Fallback to sample video if needed
|
||||
setVideoUrl("https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4");
|
||||
setVideoUrl("/videos/sample-video-30s.mp4");
|
||||
}
|
||||
}, [videoRef]);
|
||||
|
||||
|
||||
@ -34,6 +34,9 @@ interface TimelineControlsProps {
|
||||
isPreviewMode?: boolean;
|
||||
hasUnsavedChanges?: boolean;
|
||||
isIOSUninitialized?: boolean;
|
||||
isPlaying: boolean;
|
||||
setIsPlaying: (playing: boolean) => void;
|
||||
onPlayPause: () => void; // Add this prop
|
||||
}
|
||||
|
||||
// Function to calculate and constrain tooltip position to keep it on screen
|
||||
@ -77,7 +80,10 @@ const TimelineControls = ({
|
||||
onSaveSegments,
|
||||
isPreviewMode,
|
||||
hasUnsavedChanges = false,
|
||||
isIOSUninitialized = false
|
||||
isIOSUninitialized = false,
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
onPlayPause // Add this prop
|
||||
}: TimelineControlsProps) => {
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const leftHandleRef = useRef<HTMLDivElement>(null);
|
||||
@ -162,6 +168,40 @@ const TimelineControls = ({
|
||||
setClickedTime(newTime);
|
||||
setDisplayTime(newTime);
|
||||
|
||||
// Update tooltip position and type during drag
|
||||
if (timelineRef.current) {
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
const positionPercent = (newTime / duration) * 100;
|
||||
const xPos = rect.left + (rect.width * (positionPercent / 100));
|
||||
setTooltipPosition({
|
||||
x: xPos,
|
||||
y: rect.top - 10
|
||||
});
|
||||
|
||||
// Create a temporary segment with the current drag position
|
||||
const draggedSegment = {
|
||||
...segment,
|
||||
startTime: isLeft ? newTime : segment.startTime,
|
||||
endTime: isLeft ? segment.endTime : newTime
|
||||
};
|
||||
|
||||
// Check if the current marker position (currentTime) is within the dragged segment
|
||||
const isMarkerInSegment = currentTime >= draggedSegment.startTime && currentTime <= draggedSegment.endTime;
|
||||
|
||||
if (isMarkerInSegment) {
|
||||
// Show segment tooltip if marker is inside the segment
|
||||
setSelectedSegmentId(segment.id);
|
||||
setShowEmptySpaceTooltip(false);
|
||||
} else {
|
||||
// Show cutaway tooltip if marker is outside the segment
|
||||
setSelectedSegmentId(null);
|
||||
// Calculate available space for cutaway tooltip
|
||||
const availableSpace = calculateAvailableSpace(currentTime);
|
||||
setAvailableSegmentDuration(availableSpace);
|
||||
setShowEmptySpaceTooltip(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Resume playback if it was playing before
|
||||
if (wasPlaying && videoRef.current) {
|
||||
videoRef.current.play();
|
||||
@ -786,34 +826,11 @@ const TimelineControls = ({
|
||||
|
||||
// Global click handler to close tooltips when clicking outside
|
||||
useEffect(() => {
|
||||
const handleGlobalClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Close tooltips when clicking outside of tooltips and timeline marker head
|
||||
if ((selectedSegmentId !== null || showEmptySpaceTooltip) &&
|
||||
!target.closest('.empty-space-tooltip') &&
|
||||
!target.closest('.segment-tooltip') &&
|
||||
!target.closest('.timeline-marker-head') &&
|
||||
!target.closest('.clip-segment') &&
|
||||
!target.closest('.timeline-container')) {
|
||||
// Reset play state when closing tooltip
|
||||
if (isPlayingSegment) {
|
||||
setIsPlayingSegment(false);
|
||||
setActiveSegment(null);
|
||||
}
|
||||
// Also reset continuePastBoundary flag when closing tooltips
|
||||
setContinuePastBoundary(false);
|
||||
logger.debug("Closing tooltip - resetting continuePastBoundary flag");
|
||||
|
||||
setSelectedSegmentId(null);
|
||||
setShowEmptySpaceTooltip(false);
|
||||
}
|
||||
};
|
||||
// Remove the global click handler that closes tooltips
|
||||
// This keeps the popup always visible, even when clicking outside the timeline
|
||||
|
||||
document.addEventListener('mousedown', handleGlobalClick);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleGlobalClick);
|
||||
};
|
||||
// Keeping the dependency array to avoid linting errors
|
||||
return () => {};
|
||||
}, [selectedSegmentId, showEmptySpaceTooltip, isPlayingSegment]);
|
||||
|
||||
// Initialize drag handlers for trim handles
|
||||
@ -969,32 +986,49 @@ const TimelineControls = ({
|
||||
if (duration - startTime < 0.3) {
|
||||
logger.debug("Very close to end of video, ensuring tooltip can show:",
|
||||
formatDetailedTime(startTime), "video end:", formatDetailedTime(duration));
|
||||
return 0.5; // Minimum value to show tooltip
|
||||
return Math.max(0.5, remainingDuration); // Use actual remaining duration if larger
|
||||
}
|
||||
|
||||
// 2. Find the next segment (if any)
|
||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Check if we're exactly at a segment boundary (start or end of any segment)
|
||||
// Use a small tolerance for floating point comparison
|
||||
const isAtSegmentBoundary = sortedSegments.some(seg =>
|
||||
Math.abs(startTime - seg.startTime) < 0.01 ||
|
||||
Math.abs(startTime - seg.endTime) < 0.01
|
||||
);
|
||||
// Check if we're in a cutaway area near a segment boundary
|
||||
// Use a larger tolerance (100ms) for boundary detection
|
||||
const isNearSegmentBoundary = sortedSegments.some(seg => {
|
||||
const distanceToStart = Math.abs(startTime - seg.startTime);
|
||||
const distanceToEnd = Math.abs(startTime - seg.endTime);
|
||||
// Consider both start and end boundaries with different tolerances
|
||||
return (distanceToStart < 0.1 && distanceToStart > 0) || // Near start but not exactly at it
|
||||
(distanceToEnd < 0.1 && distanceToEnd > 0); // Near end but not exactly at it
|
||||
});
|
||||
|
||||
// If we're exactly at a segment boundary, return a small non-zero value to ensure tooltip shows
|
||||
if (isAtSegmentBoundary) {
|
||||
// If we're near a segment boundary in cutaway area, ensure tooltip shows
|
||||
if (isNearSegmentBoundary) {
|
||||
logger.debug("Near segment boundary in cutaway area:", formatDetailedTime(startTime));
|
||||
return 0.5; // Minimum value to show tooltip
|
||||
}
|
||||
|
||||
const nextSegment = sortedSegments.find(seg => seg.startTime > startTime);
|
||||
const prevSegment = [...sortedSegments].reverse().find(seg => seg.endTime < startTime);
|
||||
|
||||
if (nextSegment) {
|
||||
// Space available until the next segment starts
|
||||
const spaceUntilNextSegment = Math.max(0, nextSegment.startTime - startTime);
|
||||
// If we're very close to the next segment, ensure tooltip still shows
|
||||
if (spaceUntilNextSegment < 0.1) {
|
||||
return 0.5;
|
||||
}
|
||||
return Math.min(30, spaceUntilNextSegment); // Take either 30s or available space, whichever is smaller
|
||||
} else if (prevSegment) {
|
||||
// We're after the last segment, use remaining duration
|
||||
const spaceAfterPrevSegment = Math.max(0, duration - startTime);
|
||||
// If we're very close to the previous segment's end, ensure tooltip shows
|
||||
if (startTime - prevSegment.endTime < 0.1) {
|
||||
return 0.5;
|
||||
}
|
||||
return Math.min(30, spaceAfterPrevSegment);
|
||||
} else {
|
||||
// No next segment, just limited by video duration
|
||||
// No segments at all, use remaining duration
|
||||
return Math.min(30, remainingDuration);
|
||||
}
|
||||
};
|
||||
@ -1048,9 +1082,6 @@ const TimelineControls = ({
|
||||
setClickedTime(newTime);
|
||||
setDisplayTime(newTime);
|
||||
|
||||
// Special case: when clicking very close to the end of the video
|
||||
const isNearVideoEnd = duration - newTime < 0.3; // Within 300ms of the end
|
||||
|
||||
// Find if we clicked in a segment with a small tolerance for boundaries
|
||||
const segmentAtClickedTime = clipSegments.find(seg => {
|
||||
// Standard check for being inside a segment
|
||||
@ -1085,95 +1116,18 @@ const TimelineControls = ({
|
||||
|
||||
// Only process tooltip display if clicked on the timeline background or thumbnails, not on other UI elements
|
||||
if (e.target === timelineRef.current || (e.target as HTMLElement).classList.contains('timeline-thumbnail')) {
|
||||
// Special handling for near-end-of-video clicks
|
||||
if (isNearVideoEnd) {
|
||||
logger.debug("Near end of video - showing empty space tooltip");
|
||||
|
||||
// Force show the empty space tooltip
|
||||
setSelectedSegmentId(null);
|
||||
setShowEmptySpaceTooltip(true);
|
||||
setAvailableSegmentDuration(0.5); // Minimum value
|
||||
|
||||
// Calculate and set tooltip position
|
||||
let xPos;
|
||||
if (zoomLevel > 1) {
|
||||
const visibleTimelineLeft = rect.left - scrollContainerRef.current.scrollLeft;
|
||||
const clickPosPercent = newTime / duration;
|
||||
xPos = visibleTimelineLeft + (clickPosPercent * rect.width);
|
||||
} else {
|
||||
xPos = e.clientX;
|
||||
}
|
||||
|
||||
setTooltipPosition({
|
||||
x: xPos,
|
||||
y: rect.top - 10
|
||||
});
|
||||
|
||||
return; // Exit early since we've handled this special case
|
||||
}
|
||||
|
||||
// First, check if we're at a segment boundary with a small tolerance
|
||||
const isAtSegmentBoundary = clipSegments.some(seg =>
|
||||
Math.abs(newTime - seg.startTime) < 0.01 ||
|
||||
Math.abs(newTime - seg.endTime) < 0.01
|
||||
);
|
||||
|
||||
// If we're at a segment boundary, ensure we can still show a tooltip
|
||||
if (isAtSegmentBoundary) {
|
||||
logger.debug("Clicked exactly at segment boundary:", formatDetailedTime(newTime));
|
||||
|
||||
// Find the segment whose boundary we clicked on
|
||||
const boundarySegment = clipSegments.find(seg =>
|
||||
Math.abs(newTime - seg.startTime) < 0.01 ||
|
||||
Math.abs(newTime - seg.endTime) < 0.01
|
||||
);
|
||||
|
||||
if (boundarySegment) {
|
||||
// If we clicked at the exact end of a segment, show that segment's tooltip
|
||||
if (Math.abs(newTime - boundarySegment.endTime) < 0.01) {
|
||||
setSelectedSegmentId(boundarySegment.id);
|
||||
setShowEmptySpaceTooltip(false);
|
||||
|
||||
// Calculate and set tooltip position
|
||||
let xPos;
|
||||
if (zoomLevel > 1) {
|
||||
const visibleTimelineLeft = rect.left - scrollContainerRef.current.scrollLeft;
|
||||
const clickPosPercent = newTime / duration;
|
||||
xPos = visibleTimelineLeft + (clickPosPercent * rect.width);
|
||||
} else {
|
||||
xPos = e.clientX;
|
||||
}
|
||||
|
||||
setTooltipPosition({
|
||||
x: xPos,
|
||||
y: rect.top - 10
|
||||
});
|
||||
|
||||
return; // Exit early since we've handled this case
|
||||
}
|
||||
}
|
||||
|
||||
// For other boundary cases, continue to normal processing
|
||||
}
|
||||
|
||||
// Check if there's a segment at the clicked position
|
||||
if (segmentAtClickedTime) {
|
||||
setSelectedSegmentId(segmentAtClickedTime.id);
|
||||
setShowEmptySpaceTooltip(false);
|
||||
} else {
|
||||
// First, close segment tooltip if open
|
||||
// We're in a cutaway area - always show tooltip
|
||||
setSelectedSegmentId(null);
|
||||
|
||||
// Calculate the available space for a new segment
|
||||
const availableSpace = calculateAvailableSpace(newTime);
|
||||
setAvailableSegmentDuration(availableSpace);
|
||||
|
||||
// If there's no space to create even a minimal segment (at least 0.5 seconds), don't show the tooltip
|
||||
if (availableSpace < 0.5) {
|
||||
setShowEmptySpaceTooltip(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate and set tooltip position correctly for zoomed timeline
|
||||
let xPos;
|
||||
if (zoomLevel > 1) {
|
||||
@ -1191,18 +1145,20 @@ const TimelineControls = ({
|
||||
y: rect.top - 10 // Position tooltip above the timeline
|
||||
});
|
||||
|
||||
// Show the empty space tooltip
|
||||
// Always show the empty space tooltip in cutaway areas
|
||||
setShowEmptySpaceTooltip(true);
|
||||
|
||||
// Close tooltip when clicking outside
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// This is now handled by the global document click handler - just remove this listener
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
// Log the cutaway area details
|
||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
const prevSegment = [...sortedSegments].reverse().find(seg => seg.endTime < newTime);
|
||||
const nextSegment = sortedSegments.find(seg => seg.startTime > newTime);
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
logger.debug("Clicked in cutaway area:", {
|
||||
position: formatDetailedTime(newTime),
|
||||
availableSpace: formatDetailedTime(availableSpace),
|
||||
prevSegmentEnd: prevSegment ? formatDetailedTime(prevSegment.endTime) : "none",
|
||||
nextSegmentStart: nextSegment ? formatDetailedTime(nextSegment.startTime) : "none"
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -1251,10 +1207,7 @@ const TimelineControls = ({
|
||||
detail: { segmentId }
|
||||
}));
|
||||
|
||||
// Hide tooltip during drag
|
||||
setSelectedSegmentId(null);
|
||||
setShowEmptySpaceTooltip(false);
|
||||
|
||||
// Keep the tooltip visible during drag
|
||||
// Function to handle both mouse and touch movements
|
||||
const handleDragMove = (clientX: number) => {
|
||||
if (!isDragging || !timelineRef.current) return;
|
||||
@ -1262,7 +1215,35 @@ const TimelineControls = ({
|
||||
const updatedTimelineRect = timelineRef.current.getBoundingClientRect();
|
||||
const position = Math.max(0, Math.min(1, (clientX - updatedTimelineRect.left) / updatedTimelineRect.width));
|
||||
const newTime = position * duration;
|
||||
|
||||
// Create a temporary segment with the current drag position to check against
|
||||
const draggedSegment = {
|
||||
id: segmentId,
|
||||
startTime: isLeft ? newTime : originalStartTime,
|
||||
endTime: isLeft ? originalEndTime : newTime,
|
||||
name: '',
|
||||
thumbnail: ''
|
||||
};
|
||||
|
||||
// Check if the current marker position intersects with where the segment will be
|
||||
const currentSegmentStart = isLeft ? newTime : originalStartTime;
|
||||
const currentSegmentEnd = isLeft ? originalEndTime : newTime;
|
||||
const isMarkerInSegment = currentTime >= currentSegmentStart && currentTime <= currentSegmentEnd;
|
||||
|
||||
// Update tooltip based on marker intersection
|
||||
if (isMarkerInSegment) {
|
||||
// Show segment tooltip if marker is inside the segment
|
||||
setSelectedSegmentId(segmentId);
|
||||
setShowEmptySpaceTooltip(false);
|
||||
} else {
|
||||
// Show cutaway tooltip if marker is outside the segment
|
||||
setSelectedSegmentId(null);
|
||||
// Calculate available space for cutaway tooltip
|
||||
const availableSpace = calculateAvailableSpace(currentTime);
|
||||
setAvailableSegmentDuration(availableSpace);
|
||||
setShowEmptySpaceTooltip(true);
|
||||
}
|
||||
|
||||
// Find neighboring segments (exclude the current one)
|
||||
const otherSegments = clipSegments.filter(seg => seg.id !== segmentId);
|
||||
|
||||
@ -1400,10 +1381,6 @@ const TimelineControls = ({
|
||||
document.body.removeChild(overlay);
|
||||
}
|
||||
|
||||
// Keep tooltip hidden after drag
|
||||
setSelectedSegmentId(null);
|
||||
setShowEmptySpaceTooltip(false);
|
||||
|
||||
// Record the final position in history as a single action
|
||||
const finalSegments = clipSegments.map(seg => {
|
||||
if (seg.id === segmentId) {
|
||||
@ -1737,66 +1714,85 @@ const TimelineControls = ({
|
||||
});
|
||||
};
|
||||
|
||||
// Add a new useEffect hook to listen for segment deletion events
|
||||
// Add a new useEffect hook to listen for segment deletion events
|
||||
useEffect(() => {
|
||||
// Handle the segment deletion event
|
||||
const handleSegmentDelete = (event: CustomEvent) => {
|
||||
const { segmentId } = event.detail;
|
||||
|
||||
// If the deleted segment is the one with the currently open tooltip
|
||||
if (selectedSegmentId === segmentId) {
|
||||
const deletedSegmentIndex = clipSegments.findIndex(seg => seg.id === segmentId);
|
||||
if (deletedSegmentIndex !== -1) {
|
||||
const deletedSegment = clipSegments[deletedSegmentIndex];
|
||||
// Check if this was the last segment before deletion
|
||||
const remainingSegments = clipSegments.filter(seg => seg.id !== segmentId);
|
||||
if (remainingSegments.length === 0) {
|
||||
// Create a full video segment
|
||||
const fullVideoSegment: Segment = {
|
||||
id: Date.now(),
|
||||
name: 'Full Video',
|
||||
startTime: 0,
|
||||
endTime: duration,
|
||||
thumbnail: ''
|
||||
};
|
||||
|
||||
// Create and dispatch the update event to replace all segments with the full video segment
|
||||
const updateEvent = new CustomEvent('update-segments', {
|
||||
detail: {
|
||||
segments: [fullVideoSegment],
|
||||
recordHistory: true,
|
||||
action: 'create_full_video_segment'
|
||||
}
|
||||
});
|
||||
document.dispatchEvent(updateEvent);
|
||||
|
||||
// Update UI to show the segment tooltip
|
||||
setSelectedSegmentId(fullVideoSegment.id);
|
||||
setShowEmptySpaceTooltip(false);
|
||||
setClickedTime(currentTime);
|
||||
setDisplayTime(currentTime);
|
||||
setActiveSegment(fullVideoSegment);
|
||||
|
||||
// Calculate tooltip position at current time
|
||||
if (timelineRef.current) {
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
const posPercent = (currentTime / duration) * 100;
|
||||
const xPosition = rect.left + (rect.width * (posPercent / 100));
|
||||
|
||||
// We need the current time to check if we should show the cutaway tooltip
|
||||
const currentVideoTime = currentTime;
|
||||
setTooltipPosition({
|
||||
x: xPosition,
|
||||
y: rect.top - 10
|
||||
});
|
||||
|
||||
// Check if the current time was within the deleted segment
|
||||
const wasInsideDeletedSegment =
|
||||
currentVideoTime >= deletedSegment.startTime &&
|
||||
currentVideoTime <= deletedSegment.endTime;
|
||||
logger.debug("Created full video segment:", {
|
||||
id: fullVideoSegment.id,
|
||||
duration: formatDetailedTime(duration),
|
||||
currentPosition: formatDetailedTime(currentTime)
|
||||
});
|
||||
}
|
||||
} else if (selectedSegmentId === segmentId) {
|
||||
// Handle normal segment deletion
|
||||
const deletedSegment = clipSegments.find(seg => seg.id === segmentId);
|
||||
if (!deletedSegment) return;
|
||||
|
||||
// Calculate available space after deletion
|
||||
const availableSpace = calculateAvailableSpace(currentTime);
|
||||
|
||||
// Update UI to show cutaway tooltip
|
||||
setSelectedSegmentId(null);
|
||||
setShowEmptySpaceTooltip(true);
|
||||
setAvailableSegmentDuration(availableSpace);
|
||||
|
||||
// Calculate tooltip position
|
||||
if (timelineRef.current) {
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
const posPercent = (currentTime / duration) * 100;
|
||||
const xPosition = rect.left + (rect.width * (posPercent / 100));
|
||||
|
||||
// Calculate position in the middle of the deleted segment for tooltip
|
||||
const deletedSegmentMiddle = (deletedSegment.startTime + deletedSegment.endTime) / 2;
|
||||
const timeToUse = wasInsideDeletedSegment ? currentVideoTime : deletedSegmentMiddle;
|
||||
setTooltipPosition({
|
||||
x: xPosition,
|
||||
y: rect.top - 10
|
||||
});
|
||||
|
||||
// Calculate available space after deletion
|
||||
const availableSpace = calculateAvailableSpace(timeToUse);
|
||||
|
||||
// Update UI to show cutaway tooltip in place of segment tooltip
|
||||
setSelectedSegmentId(null);
|
||||
|
||||
if (availableSpace >= 0.5) {
|
||||
// Set the time for the tooltip
|
||||
setClickedTime(timeToUse);
|
||||
setDisplayTime(timeToUse);
|
||||
|
||||
// Calculate tooltip position
|
||||
if (timelineRef.current) {
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
const posPercent = (timeToUse / duration) * 100;
|
||||
const xPosition = rect.left + (rect.width * (posPercent / 100));
|
||||
|
||||
setTooltipPosition({
|
||||
x: xPosition,
|
||||
y: rect.top - 10
|
||||
});
|
||||
|
||||
// Show the empty space tooltip
|
||||
setAvailableSegmentDuration(availableSpace);
|
||||
setShowEmptySpaceTooltip(true);
|
||||
|
||||
logger.debug("Segment deleted, showing cutaway tooltip with available space:",
|
||||
formatDetailedTime(availableSpace),
|
||||
"at position:",
|
||||
formatDetailedTime(timeToUse)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Not enough space for a new segment, hide tooltips
|
||||
setShowEmptySpaceTooltip(false);
|
||||
}
|
||||
logger.debug("Segment deleted, showing cutaway tooltip:", {
|
||||
position: formatDetailedTime(currentTime),
|
||||
availableSpace: formatDetailedTime(availableSpace)
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -1808,7 +1804,7 @@ const TimelineControls = ({
|
||||
return () => {
|
||||
document.removeEventListener('delete-segment', handleSegmentDelete as EventListener);
|
||||
};
|
||||
}, [selectedSegmentId, clipSegments, currentTime, duration]);
|
||||
}, [selectedSegmentId, clipSegments, currentTime, duration, timelineRef]);
|
||||
|
||||
// Add an effect to synchronize tooltip play state with video play state
|
||||
useEffect(() => {
|
||||
@ -1816,8 +1812,124 @@ const TimelineControls = ({
|
||||
if (!video) return;
|
||||
|
||||
const handlePlay = () => {
|
||||
logger.debug("Video started playing from external control");
|
||||
setIsPlayingSegment(true);
|
||||
if (!videoRef.current) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
const currentPosition = video.currentTime;
|
||||
|
||||
// Reset continuePastBoundary flag when starting new playback
|
||||
setContinuePastBoundary(false);
|
||||
|
||||
// Find the next stopping point based on current position
|
||||
let stopTime = duration;
|
||||
let currentSegment = null;
|
||||
let nextSegment = null;
|
||||
|
||||
// First, check if we're inside a segment with high precision
|
||||
currentSegment = clipSegments.find(seg => {
|
||||
const isWithinSegment = currentPosition >= seg.startTime && currentPosition <= seg.endTime;
|
||||
const isAtExactStart = Math.abs(currentPosition - seg.startTime) < 0.001; // Within 1ms of start
|
||||
const isAtExactEnd = Math.abs(currentPosition - seg.endTime) < 0.001; // Within 1ms of end
|
||||
return isWithinSegment || isAtExactStart || isAtExactEnd;
|
||||
});
|
||||
|
||||
// Find the next segment with high precision
|
||||
nextSegment = clipSegments
|
||||
.filter(seg => {
|
||||
const isAfterCurrent = seg.startTime > currentPosition;
|
||||
const isNotAtExactPosition = Math.abs(seg.startTime - currentPosition) > 0.001;
|
||||
return isAfterCurrent && isNotAtExactPosition;
|
||||
})
|
||||
.sort((a, b) => a.startTime - b.startTime)[0];
|
||||
|
||||
// Determine where to stop based on position
|
||||
if (currentSegment) {
|
||||
// If we're in a segment, stop at its end
|
||||
stopTime = currentSegment.endTime;
|
||||
setActiveSegment(currentSegment);
|
||||
} else if (nextSegment) {
|
||||
// If we're in a cutaway and there's a next segment, stop at its start
|
||||
stopTime = nextSegment.startTime;
|
||||
// Don't set active segment since we're in a cutaway
|
||||
}
|
||||
|
||||
// Create a boundary checker function with high precision
|
||||
const checkBoundary = () => {
|
||||
if (!video) return;
|
||||
|
||||
const currentPosition = video.currentTime;
|
||||
const timeLeft = stopTime - currentPosition;
|
||||
|
||||
// If we're approaching the boundary (within 1ms) or have passed it
|
||||
if (timeLeft <= 0.001 || currentPosition >= stopTime) {
|
||||
// First pause playback
|
||||
video.pause();
|
||||
|
||||
// Force exact position with multiple verification attempts
|
||||
const setExactPosition = () => {
|
||||
if (!video) return;
|
||||
|
||||
// Set to exact boundary time
|
||||
video.currentTime = stopTime;
|
||||
onSeek(stopTime);
|
||||
setDisplayTime(stopTime);
|
||||
setClickedTime(stopTime);
|
||||
|
||||
logger.debug("Position verification:", {
|
||||
target: formatDetailedTime(stopTime),
|
||||
actual: formatDetailedTime(video.currentTime),
|
||||
difference: Math.abs(video.currentTime - stopTime).toFixed(3)
|
||||
});
|
||||
};
|
||||
|
||||
// Multiple attempts to ensure precision
|
||||
setExactPosition();
|
||||
setTimeout(setExactPosition, 10);
|
||||
setTimeout(setExactPosition, 20);
|
||||
setTimeout(setExactPosition, 50);
|
||||
|
||||
// Update UI based on where we stopped
|
||||
if (currentSegment) {
|
||||
setSelectedSegmentId(currentSegment.id);
|
||||
setShowEmptySpaceTooltip(false);
|
||||
} else if (nextSegment) {
|
||||
setSelectedSegmentId(nextSegment.id);
|
||||
setShowEmptySpaceTooltip(false);
|
||||
setActiveSegment(nextSegment);
|
||||
} else {
|
||||
setSelectedSegmentId(null);
|
||||
setShowEmptySpaceTooltip(true);
|
||||
setActiveSegment(null);
|
||||
}
|
||||
|
||||
// Remove our boundary checker
|
||||
video.removeEventListener('timeupdate', checkBoundary);
|
||||
setIsPlaying(false);
|
||||
setIsPlayingSegment(false);
|
||||
// Reset continuePastBoundary flag when stopping at boundary
|
||||
setContinuePastBoundary(false);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Start our boundary checker
|
||||
video.addEventListener('timeupdate', checkBoundary);
|
||||
|
||||
// Start playing
|
||||
video.play()
|
||||
.then(() => {
|
||||
setIsPlaying(true);
|
||||
setIsPlayingSegment(true);
|
||||
logger.debug("Playback started:", {
|
||||
from: formatDetailedTime(currentPosition),
|
||||
to: formatDetailedTime(stopTime),
|
||||
currentSegment: currentSegment ? `Segment ${currentSegment.id}` : 'None',
|
||||
nextSegment: nextSegment ? `Segment ${nextSegment.id}` : 'None'
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error playing video:", err);
|
||||
});
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
@ -1832,7 +1944,7 @@ const TimelineControls = ({
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
};
|
||||
}, []);
|
||||
}, [clipSegments, duration, onSeek]);
|
||||
|
||||
// Handle mouse movement over timeline to remember position
|
||||
const handleTimelineMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
@ -2248,8 +2360,9 @@ const TimelineControls = ({
|
||||
className="timeline-marker"
|
||||
style={{ left: `${currentTimePercent}%` }}
|
||||
>
|
||||
{/* Top circle for popup toggle */}
|
||||
<div
|
||||
className={`timeline-marker-head ${isDragging ? 'dragging' : ''}`}
|
||||
className="timeline-marker-head"
|
||||
onClick={(e) => {
|
||||
// Prevent event propagation to avoid triggering the timeline container click
|
||||
e.stopPropagation();
|
||||
@ -2300,13 +2413,20 @@ const TimelineControls = ({
|
||||
}
|
||||
}
|
||||
}}
|
||||
onMouseDown={startDrag}
|
||||
onTouchStart={startTouchDrag}
|
||||
>
|
||||
<span className="timeline-marker-head-icon">
|
||||
{selectedSegmentId || showEmptySpaceTooltip ? '-' : '+'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bottom circle for dragging */}
|
||||
<div
|
||||
className={`timeline-marker-drag ${isDragging ? 'dragging' : ''}`}
|
||||
onMouseDown={startDrag}
|
||||
onTouchStart={startTouchDrag}
|
||||
>
|
||||
<span className="timeline-marker-drag-icon">⋮</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trim Line Markers - hidden when segments exist */}
|
||||
@ -2461,139 +2581,82 @@ const TimelineControls = ({
|
||||
>
|
||||
<img src={playFromBeginningIcon} alt="Play from beginning" style={{width: '24px', height: '24px'}} />
|
||||
</button>
|
||||
<button
|
||||
className={`tooltip-action-btn ${isPlayingSegment ? 'pause' : 'play'}`}
|
||||
data-tooltip={isPlayingSegment ? "Pause playback" : "Play from current position"}
|
||||
{/* <button
|
||||
className={`tooltip-action-btn ${isPlaying ? 'pause' : 'play'}`}
|
||||
data-tooltip={isPlaying ? "Pause playback" : "Play from current position"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Find the selected segment
|
||||
const segment = clipSegments.find(seg => seg.id === selectedSegmentId);
|
||||
if (segment && videoRef.current) {
|
||||
if (isPlayingSegment) {
|
||||
// If already playing, pause the video
|
||||
// Find the current segment
|
||||
const currentSegment = clipSegments.find(seg =>
|
||||
currentTime >= seg.startTime && currentTime <= seg.endTime
|
||||
);
|
||||
|
||||
if (isPlaying) {
|
||||
// If playing, just pause
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
setIsPlayingSegment(false);
|
||||
// Reset continuePastBoundary when stopping playback
|
||||
setContinuePastBoundary(false);
|
||||
logger.debug("Pause clicked - resetting continuePastBoundary flag");
|
||||
} else {
|
||||
// Enable continuePastBoundary flag when user explicitly clicks play
|
||||
// This will allow playback to continue even if we're at segment boundary
|
||||
setContinuePastBoundary(true);
|
||||
logger.debug("Setting continuePastBoundary=true to allow playback through boundaries");
|
||||
|
||||
// Keep current position (use the current time marker) and just start playing
|
||||
// Don't seek to segment start - this allows continuing from where the marker is
|
||||
logger.debug("Play from current position - initial time:", formatDetailedTime(videoRef.current.currentTime));
|
||||
|
||||
// Determine if we're at the segment end
|
||||
// Exact check - we want to be very precise here
|
||||
const isExactlyAtEnd = Math.abs(videoRef.current.currentTime - segment.endTime) < 0.001;
|
||||
// Near check - for cases where we're very close but not exactly at the end
|
||||
const isNearEnd = Math.abs(videoRef.current.currentTime - segment.endTime) < 0.05;
|
||||
|
||||
if (isExactlyAtEnd || isNearEnd) {
|
||||
// Check if there's a segment immediately after this one
|
||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
const nextSegmentIndex = sortedSegments.findIndex(seg => seg.id === segment.id) + 1;
|
||||
const nextSegment = nextSegmentIndex < sortedSegments.length ? sortedSegments[nextSegmentIndex] : null;
|
||||
|
||||
// If there's an adjacent segment (no gap between segments)
|
||||
if (nextSegment && Math.abs(nextSegment.startTime - segment.endTime) < 0.1) {
|
||||
// Move to the start of the next segment
|
||||
logger.debug(`At segment boundary: Moving to adjacent segment ${nextSegment.id}`);
|
||||
videoRef.current.currentTime = nextSegment.startTime;
|
||||
setSelectedSegmentId(nextSegment.id);
|
||||
setActiveSegment(nextSegment);
|
||||
setDisplayTime(nextSegment.startTime);
|
||||
setClickedTime(nextSegment.startTime);
|
||||
|
||||
// Play from this next segment
|
||||
videoRef.current.play()
|
||||
.then(() => {
|
||||
setIsPlayingSegment(true);
|
||||
logger.debug("Playing from adjacent segment");
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error playing from adjacent segment:", err);
|
||||
});
|
||||
|
||||
return; // Exit early since we've handled this case
|
||||
}
|
||||
|
||||
// If we're at or near the segment end, move significantly past it
|
||||
// This ensures we completely bypass the end boundary
|
||||
const newPosition = segment.endTime + 0.5; // Move half a second past end
|
||||
|
||||
logger.debug("At segment end - repositioning to continue beyond segment boundary:",
|
||||
formatDetailedTime(videoRef.current.currentTime),
|
||||
"->", formatDetailedTime(newPosition)
|
||||
);
|
||||
|
||||
videoRef.current.currentTime = newPosition;
|
||||
|
||||
// Don't set active segment for boundary checking
|
||||
// to allow playback to continue past the segment
|
||||
setActiveSegment(null);
|
||||
|
||||
// Set a flag in sessionStorage to remember we're in "continue past segment" mode
|
||||
// This is an extra safeguard against reactivation of boundary checking
|
||||
sessionStorage.setItem('continuingPastSegment', 'true');
|
||||
sessionStorage.setItem('lastSegmentId', segment.id.toString());
|
||||
logger.debug("Continuing past segment boundary mode activated");
|
||||
|
||||
} else {
|
||||
// Normal case - not at segment end
|
||||
// Make sure we're within the segment's bounds
|
||||
const isWithinSegment =
|
||||
videoRef.current.currentTime >= segment.startTime &&
|
||||
videoRef.current.currentTime <= segment.endTime;
|
||||
|
||||
logger.debug("Current position check:", {
|
||||
currentTime: formatDetailedTime(videoRef.current.currentTime),
|
||||
segmentStart: formatDetailedTime(segment.startTime),
|
||||
segmentEnd: formatDetailedTime(segment.endTime),
|
||||
isWithinSegment: isWithinSegment
|
||||
});
|
||||
|
||||
// Only adjust position if we're outside the segment bounds
|
||||
if (!isWithinSegment) {
|
||||
// If outside segment bounds, move to current marker position
|
||||
// or to segment start if marker is before segment
|
||||
const newPosition = Math.max(segment.startTime, Math.min(segment.endTime, currentTime));
|
||||
logger.debug("Adjusting position to be within segment:", formatDetailedTime(newPosition));
|
||||
videoRef.current.currentTime = newPosition;
|
||||
} else {
|
||||
logger.debug("Keeping current position for playback");
|
||||
}
|
||||
|
||||
// Set active segment for boundary checking
|
||||
setActiveSegment(segment);
|
||||
logger.debug("Set active segment for boundary checking:", segment.id);
|
||||
}
|
||||
|
||||
// Play the video from the current position
|
||||
}
|
||||
} else {
|
||||
// If starting playback, set the active segment
|
||||
if (currentSegment) {
|
||||
setActiveSegment(currentSegment);
|
||||
}
|
||||
|
||||
// Reset continuation flag when starting new playback
|
||||
setContinuePastBoundary(false);
|
||||
|
||||
if (videoRef.current) {
|
||||
videoRef.current.play()
|
||||
.then(() => {
|
||||
setIsPlayingSegment(true);
|
||||
logger.debug("Play clicked - continuing from current position:", formatDetailedTime(videoRef.current?.currentTime || 0));
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error starting playback:", err);
|
||||
console.error("Error playing video:", err);
|
||||
setIsPlayingSegment(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Don't close the tooltip, keep it visible while playing
|
||||
}}
|
||||
>
|
||||
{isPlayingSegment ? (
|
||||
{isPlaying ? (
|
||||
<img src={pauseIcon} alt="Pause" style={{width: '24px', height: '24px'}} />
|
||||
) : (
|
||||
<img src={playIcon} alt="Play" style={{width: '24px', height: '24px'}} />
|
||||
)}
|
||||
</button> */}
|
||||
|
||||
{/* Play/Pause button for empty space - Same as main play/pause button */}
|
||||
<button
|
||||
className={`tooltip-action-btn ${isPlaying ? 'pause' : 'play'}`}
|
||||
data-tooltip={isPlaying ? "Pause playback" : "Play from current position"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (isPlaying) {
|
||||
|
||||
// If playing, just pause
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
setIsPlayingSegment(false);
|
||||
setContinuePastBoundary(false);
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
onPlayPause();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<img src={pauseIcon} alt="Pause" style={{width: '24px', height: '24px'}} />
|
||||
) : (
|
||||
<img src={playIcon} alt="Play" style={{width: '24px', height: '24px'}} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="tooltip-action-btn set-in"
|
||||
data-tooltip="Set start point at current position"
|
||||
@ -2979,16 +3042,17 @@ const TimelineControls = ({
|
||||
>
|
||||
<img src={playFromBeginningIcon} alt="Play from beginning" style={{width: '24px', height: '24px'}} />
|
||||
</button>
|
||||
|
||||
|
||||
{/* Play/Pause button for empty space */}
|
||||
<button
|
||||
className={`tooltip-action-btn ${isPlayingSegment ? 'pause' : 'play'}`}
|
||||
data-tooltip={isPlayingSegment ? "Pause playback" : "Play from here until next segment"}
|
||||
{/* <button
|
||||
className={`tooltip-action-btn ${isPlaying ? 'pause' : 'play'}`}
|
||||
data-tooltip={isPlaying ? "Pause playback" : "Play from here until next segment"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (videoRef.current) {
|
||||
if (isPlayingSegment) {
|
||||
if (isPlaying) {
|
||||
// If already playing, pause the video
|
||||
videoRef.current.pause();
|
||||
setIsPlayingSegment(false);
|
||||
@ -3216,7 +3280,36 @@ const TimelineControls = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPlayingSegment ? (
|
||||
{isPlaying ? (
|
||||
<img src={pauseIcon} alt="Pause" style={{width: '24px', height: '24px'}} />
|
||||
) : (
|
||||
<img src={playIcon} alt="Play" style={{width: '24px', height: '24px'}} />
|
||||
)}
|
||||
</button> */}
|
||||
|
||||
{/* Play/Pause button for empty space - Same as main play/pause button */}
|
||||
<button
|
||||
className={`tooltip-action-btn ${isPlaying ? 'pause' : 'play'}`}
|
||||
data-tooltip={isPlaying ? "Pause playback" : "Play from here until next segment"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (isPlaying) {
|
||||
|
||||
// If playing, just pause
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
setIsPlayingSegment(false);
|
||||
setContinuePastBoundary(false);
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
onPlayPause();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<img src={pauseIcon} alt="Pause" style={{width: '24px', height: '24px'}} />
|
||||
) : (
|
||||
<img src={playIcon} alt="Play" style={{width: '24px', height: '24px'}} />
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import React, { useRef, useEffect, useState } from "react";
|
||||
import { formatTime, formatDetailedTime } from "@/lib/timeUtils";
|
||||
import logger from '../lib/logger';
|
||||
import '../styles/VideoPlayer.css';
|
||||
|
||||
interface VideoPlayerProps {
|
||||
@ -13,7 +14,7 @@ interface VideoPlayerProps {
|
||||
onToggleMute?: () => void;
|
||||
}
|
||||
|
||||
const VideoPlayer = ({
|
||||
const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
videoRef,
|
||||
currentTime,
|
||||
duration,
|
||||
@ -22,7 +23,7 @@ const VideoPlayer = ({
|
||||
onPlayPause,
|
||||
onSeek,
|
||||
onToggleMute
|
||||
}: VideoPlayerProps) => {
|
||||
}) => {
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const [isIOS, setIsIOS] = useState(false);
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
@ -34,7 +35,7 @@ const VideoPlayer = ({
|
||||
|
||||
const sampleVideoUrl = typeof window !== 'undefined' &&
|
||||
(window as any).MEDIA_DATA?.videoUrl ||
|
||||
"https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
|
||||
"/videos/sample-video-30s.mp4";
|
||||
|
||||
// Detect iOS device
|
||||
useEffect(() => {
|
||||
@ -64,14 +65,48 @@ const VideoPlayer = ({
|
||||
|
||||
// Add iOS-specific attributes to prevent fullscreen playback
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
// These attributes need to be set directly on the DOM element
|
||||
// for iOS Safari to respect inline playback
|
||||
videoRef.current.setAttribute('playsinline', 'true');
|
||||
videoRef.current.setAttribute('webkit-playsinline', 'true');
|
||||
videoRef.current.setAttribute('x-webkit-airplay', 'allow');
|
||||
}
|
||||
}, [videoRef]);
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
// 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');
|
||||
|
||||
// Store the last known good position for iOS
|
||||
const handleTimeUpdate = () => {
|
||||
if (!isDraggingProgressRef.current) {
|
||||
setLastPosition(video.currentTime);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.lastSeekedPosition = video.currentTime;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle iOS-specific play/pause state
|
||||
const handlePlay = () => {
|
||||
logger.debug('Video play event fired');
|
||||
if (isIOS) {
|
||||
setHasInitialized(true);
|
||||
localStorage.setItem('video_initialized', 'true');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
logger.debug('Video pause event fired');
|
||||
};
|
||||
|
||||
video.addEventListener('timeupdate', handleTimeUpdate);
|
||||
video.addEventListener('play', handlePlay);
|
||||
video.addEventListener('pause', handlePause);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
};
|
||||
}, [videoRef, isIOS, isDraggingProgressRef]);
|
||||
|
||||
// Save current time to lastPosition when it changes (from external seeking)
|
||||
useEffect(() => {
|
||||
@ -248,23 +283,19 @@ const VideoPlayer = ({
|
||||
if (video.paused) {
|
||||
// For iOS Safari: Before playing, explicitly seek to the remembered position
|
||||
if (isIOS && lastPosition !== null && lastPosition > 0) {
|
||||
console.log("iOS: Explicitly setting position before play:", lastPosition);
|
||||
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
|
||||
// This is critical for iOS Safari
|
||||
setTimeout(() => {
|
||||
if (videoRef.current) {
|
||||
// Try to play with proper promise handling
|
||||
videoRef.current.play()
|
||||
.then(() => {
|
||||
console.log("iOS: Play started successfully at position:", videoRef.current?.currentTime);
|
||||
|
||||
// Mark as initialized
|
||||
setHasInitialized(true);
|
||||
localStorage.setItem('video_initialized', 'true');
|
||||
logger.debug("iOS: Play started successfully at position:", videoRef.current?.currentTime);
|
||||
onPlayPause(); // Update parent state after successful play
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("iOS: Error playing video:", err);
|
||||
@ -275,19 +306,18 @@ const VideoPlayer = ({
|
||||
// Normal play (non-iOS or no remembered position)
|
||||
video.play()
|
||||
.then(() => {
|
||||
console.log("Normal: Play started successfully");
|
||||
logger.debug("Normal: Play started successfully");
|
||||
onPlayPause(); // Update parent state after successful play
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error playing video:", err);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// If playing, just pause
|
||||
// If playing, pause and update state
|
||||
video.pause();
|
||||
onPlayPause();
|
||||
}
|
||||
|
||||
// Call the parent component's onPlayPause to update state
|
||||
onPlayPause();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@ -145,6 +145,7 @@ const useVideoTrimmer = () => {
|
||||
// Only update isPlaying if we're not in preview mode
|
||||
if (!isPreviewMode) {
|
||||
setIsPlaying(true);
|
||||
setVideoInitialized(true);
|
||||
}
|
||||
};
|
||||
|
||||
@ -932,11 +933,27 @@ const useVideoTrimmer = () => {
|
||||
saveState('save_segments');
|
||||
};
|
||||
|
||||
// Handle seeking with mobile check
|
||||
const handleMobileSafeSeek = (time: number) => {
|
||||
// Only allow seeking if not on mobile or if video has been played
|
||||
if (!isMobile || videoInitialized) {
|
||||
seekVideo(time);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if device is mobile
|
||||
const isMobile = typeof window !== 'undefined' && /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(navigator.userAgent);
|
||||
|
||||
// Add videoInitialized state
|
||||
const [videoInitialized, setVideoInitialized] = useState(false);
|
||||
|
||||
return {
|
||||
videoRef,
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
isMuted,
|
||||
isPreviewMode,
|
||||
thumbnails,
|
||||
trimStart,
|
||||
@ -944,25 +961,25 @@ const useVideoTrimmer = () => {
|
||||
splitPoints,
|
||||
zoomLevel,
|
||||
clipSegments,
|
||||
hasUnsavedChanges,
|
||||
historyPosition,
|
||||
history,
|
||||
isMuted,
|
||||
hasUnsavedChanges, // Add unsaved changes flag to the return object
|
||||
playPauseVideo,
|
||||
seekVideo,
|
||||
handleTrimStartChange,
|
||||
handleTrimEndChange,
|
||||
handleZoomChange,
|
||||
handleMobileSafeSeek,
|
||||
handleSplit,
|
||||
handleReset,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
handlePreview,
|
||||
handlePlay,
|
||||
handleZoomChange,
|
||||
toggleMute,
|
||||
handleSave,
|
||||
handleSaveACopy,
|
||||
handleSaveSegments,
|
||||
isMobile,
|
||||
videoInitialized,
|
||||
setVideoInitialized,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -65,7 +65,7 @@
|
||||
background-color: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 2.5rem;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
|
||||
@ -56,31 +56,51 @@
|
||||
|
||||
.timeline-marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background-color: red;
|
||||
z-index: 30;
|
||||
height: 82px; /* Increased height to extend below timeline */
|
||||
width: 2px;
|
||||
background-color: #000;
|
||||
transform: translateX(-50%);
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeline-marker-head {
|
||||
position: absolute;
|
||||
top: -11px;
|
||||
top: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: red;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: #ef4444;
|
||||
border-radius: 50%;
|
||||
pointer-events: auto;
|
||||
cursor: grab;
|
||||
z-index: 31;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.1s ease, background-color 0.1s ease;
|
||||
touch-action: none;
|
||||
pointer-events: auto;
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
.timeline-marker-drag {
|
||||
position: absolute;
|
||||
bottom: -12px; /* Changed from -6px to -12px to move it further down */
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: #4b5563;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: auto;
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
.timeline-marker-drag.dragging {
|
||||
cursor: grabbing;
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
.timeline-marker-head-icon {
|
||||
@ -88,13 +108,16 @@
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.timeline-marker-head.dragging {
|
||||
transform: translateX(-50%) scale(1.2);
|
||||
cursor: grabbing;
|
||||
background-color: #ff3333;
|
||||
box-shadow: 0 0 8px rgba(255, 0, 0, 0.6);
|
||||
.timeline-marker-drag-icon {
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
transform: rotate(90deg);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.trim-line-marker {
|
||||
@ -258,27 +281,27 @@
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
height: 52px; /* Increased height for touch devices */
|
||||
}
|
||||
|
||||
.timeline-marker-head {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
top: -13px;
|
||||
}
|
||||
|
||||
.timeline-marker-drag {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
bottom: -18px; /* Adjusted for larger touch target */
|
||||
}
|
||||
|
||||
.timeline-marker-head.dragging {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
top: -15px;
|
||||
}
|
||||
|
||||
/* Create a larger invisible touch target */
|
||||
.timeline-marker-head:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: -10px;
|
||||
right: -10px;
|
||||
bottom: -10px;
|
||||
}
|
||||
}
|
||||
|
||||
.segment-tooltip,
|
||||
@ -292,7 +315,8 @@
|
||||
min-width: 150px;
|
||||
text-align: center;
|
||||
pointer-events: auto;
|
||||
top: -90px !important;
|
||||
top: -100px !important;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.segment-tooltip:after,
|
||||
@ -309,6 +333,21 @@
|
||||
border-top: 5px solid white;
|
||||
}
|
||||
|
||||
.segment-tooltip:before,
|
||||
.empty-space-tooltip:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 6px solid rgba(0, 0, 0, 0.1);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.tooltip-time {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
|
||||
@ -61,17 +61,36 @@
|
||||
}
|
||||
.video-player-container {
|
||||
position: relative;
|
||||
background-color: black;
|
||||
width: 100%;
|
||||
background: #000;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
aspect-ratio: 16/9;
|
||||
/* Prevent iOS Safari from showing default video controls */
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.video-player-container video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
/* Force hardware acceleration */
|
||||
transform: translateZ(0);
|
||||
-webkit-transform: translateZ(0);
|
||||
/* Prevent iOS Safari from showing default video controls */
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* iOS-specific styles */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.video-player-container video {
|
||||
/* Additional iOS optimizations */
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
}
|
||||
|
||||
.play-pause-indicator {
|
||||
@ -85,36 +104,67 @@
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
&.play-icon::before {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 15px solid transparent;
|
||||
border-bottom: 15px solid transparent;
|
||||
border-left: 25px solid white;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
&.pause-icon::before {
|
||||
width: 20px;
|
||||
height: 25px;
|
||||
border-left: 6px solid white;
|
||||
border-right: 6px solid white;
|
||||
}
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.video-player-container:hover .play-pause-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.play-pause-indicator::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.play-pause-indicator.play-icon::before {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 15px solid transparent;
|
||||
border-bottom: 15px solid transparent;
|
||||
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;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.ios-play-message {
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 0.5rem;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 0.7; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.05); }
|
||||
100% { opacity: 0.7; transform: scale(1); }
|
||||
}
|
||||
|
||||
.video-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
}
|
||||
|
||||
.page-sidebar {
|
||||
z-index: +6;
|
||||
z-index: +20;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
z-index: +5;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user