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.*
|
vite.config.ts.*
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
yt.readme.md
|
yt.readme.md
|
||||||
|
client/public/videos/sample-video.mp4
|
||||||
|
|||||||
Binary file not shown.
@ -1,4 +1,6 @@
|
|||||||
import { useRef, useEffect, useState } from "react";
|
import { useRef, useEffect, useState } from "react";
|
||||||
|
import { formatTime, formatDetailedTime } from "./lib/timeUtils";
|
||||||
|
import logger from "./lib/logger";
|
||||||
import VideoPlayer from "@/components/VideoPlayer";
|
import VideoPlayer from "@/components/VideoPlayer";
|
||||||
import TimelineControls from "@/components/TimelineControls";
|
import TimelineControls from "@/components/TimelineControls";
|
||||||
import EditingTools from "@/components/EditingTools";
|
import EditingTools from "@/components/EditingTools";
|
||||||
@ -7,85 +9,48 @@ import MobilePlayPrompt from "@/components/IOSPlayPrompt";
|
|||||||
import useVideoTrimmer from "@/hooks/useVideoTrimmer";
|
import useVideoTrimmer from "@/hooks/useVideoTrimmer";
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
|
||||||
const [videoInitialized, setVideoInitialized] = useState(false);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
videoRef,
|
videoRef,
|
||||||
currentTime,
|
currentTime,
|
||||||
duration,
|
duration,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
isPreviewMode,
|
setIsPlaying,
|
||||||
isMuted,
|
isMuted,
|
||||||
|
isPreviewMode,
|
||||||
thumbnails,
|
thumbnails,
|
||||||
trimStart,
|
trimStart,
|
||||||
trimEnd,
|
trimEnd,
|
||||||
splitPoints,
|
splitPoints,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
clipSegments,
|
clipSegments,
|
||||||
|
hasUnsavedChanges,
|
||||||
historyPosition,
|
historyPosition,
|
||||||
history,
|
history,
|
||||||
hasUnsavedChanges,
|
|
||||||
playPauseVideo,
|
|
||||||
seekVideo,
|
|
||||||
handleTrimStartChange,
|
handleTrimStartChange,
|
||||||
handleTrimEndChange,
|
handleTrimEndChange,
|
||||||
|
handleZoomChange,
|
||||||
|
handleMobileSafeSeek,
|
||||||
handleSplit,
|
handleSplit,
|
||||||
handleReset,
|
handleReset,
|
||||||
handleUndo,
|
handleUndo,
|
||||||
handleRedo,
|
handleRedo,
|
||||||
handlePreview,
|
handlePreview,
|
||||||
handlePlay,
|
|
||||||
handleZoomChange,
|
|
||||||
toggleMute,
|
toggleMute,
|
||||||
handleSave,
|
handleSave,
|
||||||
handleSaveACopy,
|
handleSaveACopy,
|
||||||
handleSaveSegments,
|
handleSaveSegments,
|
||||||
|
isMobile,
|
||||||
|
videoInitialized,
|
||||||
|
setVideoInitialized,
|
||||||
} = useVideoTrimmer();
|
} = 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
|
// Function to play from the beginning
|
||||||
const playFromBeginning = () => {
|
const playFromBeginning = () => {
|
||||||
if (videoRef.current) {
|
if (videoRef.current) {
|
||||||
videoRef.current.currentTime = 0;
|
videoRef.current.currentTime = 0;
|
||||||
seekVideo(0);
|
handleMobileSafeSeek(0);
|
||||||
if (!isPlaying) {
|
if (!isPlaying) {
|
||||||
playPauseVideo();
|
handlePlay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -93,80 +58,176 @@ const App = () => {
|
|||||||
// Function to jump 15 seconds backward
|
// Function to jump 15 seconds backward
|
||||||
const jumpBackward15 = () => {
|
const jumpBackward15 = () => {
|
||||||
const newTime = Math.max(0, currentTime - 15);
|
const newTime = Math.max(0, currentTime - 15);
|
||||||
seekVideo(newTime);
|
handleMobileSafeSeek(newTime);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to jump 15 seconds forward
|
// Function to jump 15 seconds forward
|
||||||
const jumpForward15 = () => {
|
const jumpForward15 = () => {
|
||||||
const newTime = Math.min(duration, currentTime + 15);
|
const newTime = Math.min(duration, currentTime + 15);
|
||||||
seekVideo(newTime);
|
handleMobileSafeSeek(newTime);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start continuous 50ms increment when button is held
|
const handlePlay = () => {
|
||||||
const startIncrement = (e: React.MouseEvent | React.TouchEvent) => {
|
if (!videoRef.current) return;
|
||||||
// Prevent default to avoid text selection
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
|
const video = videoRef.current;
|
||||||
|
|
||||||
// First immediate adjustment
|
// If already playing, just pause the video
|
||||||
seekVideo(Math.min(duration, currentTime + 0.05));
|
if (isPlaying) {
|
||||||
|
video.pause();
|
||||||
// Setup continuous adjustment
|
setIsPlaying(false);
|
||||||
incrementIntervalRef.current = setInterval(() => {
|
return;
|
||||||
const currentVideoTime = videoRef.current?.currentTime || 0;
|
|
||||||
const newTime = Math.min(duration, currentVideoTime + 0.05);
|
|
||||||
seekVideo(newTime);
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Stop continuous increment
|
|
||||||
const stopIncrement = () => {
|
|
||||||
if (incrementIntervalRef.current) {
|
|
||||||
clearInterval(incrementIntervalRef.current);
|
|
||||||
incrementIntervalRef.current = null;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Start continuous 50ms decrement when button is held
|
|
||||||
const startDecrement = (e: React.MouseEvent | React.TouchEvent) => {
|
|
||||||
// Prevent default to avoid text selection
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
|
const currentPosition = Number(video.currentTime.toFixed(6)); // Fix to microsecond precision
|
||||||
|
|
||||||
// First immediate adjustment
|
// Find the next stopping point based on current position
|
||||||
seekVideo(Math.max(0, currentTime - 0.05));
|
let stopTime = duration;
|
||||||
|
let currentSegment = null;
|
||||||
|
let nextSegment = null;
|
||||||
|
|
||||||
// Setup continuous adjustment
|
// Sort segments by start time to ensure correct order
|
||||||
decrementIntervalRef.current = setInterval(() => {
|
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||||
const currentVideoTime = videoRef.current?.currentTime || 0;
|
|
||||||
const newTime = Math.max(0, currentVideoTime - 0.05);
|
// First, check if we're inside a segment or exactly at its start/end
|
||||||
seekVideo(newTime);
|
currentSegment = sortedSegments.find(seg => {
|
||||||
}, 100);
|
const segStartTime = Number(seg.startTime.toFixed(6));
|
||||||
};
|
const segEndTime = Number(seg.endTime.toFixed(6));
|
||||||
|
|
||||||
// Stop continuous decrement
|
// Check if we're inside the segment
|
||||||
const stopDecrement = () => {
|
if (currentPosition > segStartTime && currentPosition < segEndTime) {
|
||||||
if (decrementIntervalRef.current) {
|
return true;
|
||||||
clearInterval(decrementIntervalRef.current);
|
}
|
||||||
decrementIntervalRef.current = null;
|
// 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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
// Determine where to stop based on position
|
||||||
// Handle seeking with mobile check
|
if (currentSegment) {
|
||||||
const handleMobileSafeSeek = (time: number) => {
|
// If we're in a segment, stop at its end
|
||||||
// Only allow seeking if not on mobile or if video has been played
|
stopTime = Number(currentSegment.endTime.toFixed(6));
|
||||||
if (!isMobile || videoInitialized) {
|
} else if (nextSegment) {
|
||||||
seekVideo(time);
|
// 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 (
|
return (
|
||||||
<div className="bg-background min-h-screen">
|
<div className="bg-background min-h-screen">
|
||||||
<MobilePlayPrompt
|
<MobilePlayPrompt
|
||||||
videoRef={videoRef}
|
videoRef={videoRef}
|
||||||
onPlay={playPauseVideo}
|
onPlay={handlePlay}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="container mx-auto px-4 py-6 max-w-6xl">
|
<div className="container mx-auto px-4 py-6 max-w-6xl">
|
||||||
@ -177,7 +238,7 @@ const App = () => {
|
|||||||
duration={duration}
|
duration={duration}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
isMuted={isMuted}
|
isMuted={isMuted}
|
||||||
onPlayPause={playPauseVideo}
|
onPlayPause={handlePlay}
|
||||||
onSeek={handleMobileSafeSeek}
|
onSeek={handleMobileSafeSeek}
|
||||||
onToggleMute={toggleMute}
|
onToggleMute={toggleMute}
|
||||||
/>
|
/>
|
||||||
@ -217,6 +278,9 @@ const App = () => {
|
|||||||
isPreviewMode={isPreviewMode}
|
isPreviewMode={isPreviewMode}
|
||||||
hasUnsavedChanges={hasUnsavedChanges}
|
hasUnsavedChanges={hasUnsavedChanges}
|
||||||
isIOSUninitialized={isMobile && !videoInitialized}
|
isIOSUninitialized={isMobile && !videoInitialized}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
setIsPlaying={setIsPlaying}
|
||||||
|
onPlayPause={handlePlay}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Clip Segments */}
|
{/* Clip Segments */}
|
||||||
|
|||||||
@ -47,7 +47,7 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
|
|||||||
return (
|
return (
|
||||||
<div className="mobile-play-prompt-overlay">
|
<div className="mobile-play-prompt-overlay">
|
||||||
<div className="mobile-play-prompt">
|
<div className="mobile-play-prompt">
|
||||||
<h3>Mobile Device Notice</h3>
|
{/* <h3>Mobile Device Notice</h3>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
For the best video editing experience on mobile devices, you need to <strong>play the video first</strong> before
|
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>After the video starts, you can pause it</li>
|
||||||
<li>Then you'll be able to use all timeline controls</li>
|
<li>Then you'll be able to use all timeline controls</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div> */}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="mobile-play-button"
|
className="mobile-play-button"
|
||||||
onClick={handlePlayClick}
|
onClick={handlePlayClick}
|
||||||
>
|
>
|
||||||
Play Video Now
|
Click to start editing...
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -37,7 +37,7 @@ const IOSVideoPlayer = ({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback to sample video if needed
|
// Fallback to sample video if needed
|
||||||
setVideoUrl("https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4");
|
setVideoUrl("/videos/sample-video-30s.mp4");
|
||||||
}
|
}
|
||||||
}, [videoRef]);
|
}, [videoRef]);
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,9 @@ interface TimelineControlsProps {
|
|||||||
isPreviewMode?: boolean;
|
isPreviewMode?: boolean;
|
||||||
hasUnsavedChanges?: boolean;
|
hasUnsavedChanges?: boolean;
|
||||||
isIOSUninitialized?: 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
|
// Function to calculate and constrain tooltip position to keep it on screen
|
||||||
@ -77,7 +80,10 @@ const TimelineControls = ({
|
|||||||
onSaveSegments,
|
onSaveSegments,
|
||||||
isPreviewMode,
|
isPreviewMode,
|
||||||
hasUnsavedChanges = false,
|
hasUnsavedChanges = false,
|
||||||
isIOSUninitialized = false
|
isIOSUninitialized = false,
|
||||||
|
isPlaying,
|
||||||
|
setIsPlaying,
|
||||||
|
onPlayPause // Add this prop
|
||||||
}: TimelineControlsProps) => {
|
}: TimelineControlsProps) => {
|
||||||
const timelineRef = useRef<HTMLDivElement>(null);
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
const leftHandleRef = useRef<HTMLDivElement>(null);
|
const leftHandleRef = useRef<HTMLDivElement>(null);
|
||||||
@ -162,6 +168,40 @@ const TimelineControls = ({
|
|||||||
setClickedTime(newTime);
|
setClickedTime(newTime);
|
||||||
setDisplayTime(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
|
// Resume playback if it was playing before
|
||||||
if (wasPlaying && videoRef.current) {
|
if (wasPlaying && videoRef.current) {
|
||||||
videoRef.current.play();
|
videoRef.current.play();
|
||||||
@ -786,34 +826,11 @@ const TimelineControls = ({
|
|||||||
|
|
||||||
// Global click handler to close tooltips when clicking outside
|
// Global click handler to close tooltips when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleGlobalClick = (event: MouseEvent) => {
|
// Remove the global click handler that closes tooltips
|
||||||
const target = event.target as HTMLElement;
|
// This keeps the popup always visible, even when clicking outside the timeline
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleGlobalClick);
|
// Keeping the dependency array to avoid linting errors
|
||||||
return () => {
|
return () => {};
|
||||||
document.removeEventListener('mousedown', handleGlobalClick);
|
|
||||||
};
|
|
||||||
}, [selectedSegmentId, showEmptySpaceTooltip, isPlayingSegment]);
|
}, [selectedSegmentId, showEmptySpaceTooltip, isPlayingSegment]);
|
||||||
|
|
||||||
// Initialize drag handlers for trim handles
|
// Initialize drag handlers for trim handles
|
||||||
@ -969,32 +986,49 @@ const TimelineControls = ({
|
|||||||
if (duration - startTime < 0.3) {
|
if (duration - startTime < 0.3) {
|
||||||
logger.debug("Very close to end of video, ensuring tooltip can show:",
|
logger.debug("Very close to end of video, ensuring tooltip can show:",
|
||||||
formatDetailedTime(startTime), "video end:", formatDetailedTime(duration));
|
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)
|
// 2. Find the next segment (if any)
|
||||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
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)
|
// Check if we're in a cutaway area near a segment boundary
|
||||||
// Use a small tolerance for floating point comparison
|
// Use a larger tolerance (100ms) for boundary detection
|
||||||
const isAtSegmentBoundary = sortedSegments.some(seg =>
|
const isNearSegmentBoundary = sortedSegments.some(seg => {
|
||||||
Math.abs(startTime - seg.startTime) < 0.01 ||
|
const distanceToStart = Math.abs(startTime - seg.startTime);
|
||||||
Math.abs(startTime - seg.endTime) < 0.01
|
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 we're near a segment boundary in cutaway area, ensure tooltip shows
|
||||||
if (isAtSegmentBoundary) {
|
if (isNearSegmentBoundary) {
|
||||||
|
logger.debug("Near segment boundary in cutaway area:", formatDetailedTime(startTime));
|
||||||
return 0.5; // Minimum value to show tooltip
|
return 0.5; // Minimum value to show tooltip
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextSegment = sortedSegments.find(seg => seg.startTime > startTime);
|
const nextSegment = sortedSegments.find(seg => seg.startTime > startTime);
|
||||||
|
const prevSegment = [...sortedSegments].reverse().find(seg => seg.endTime < startTime);
|
||||||
|
|
||||||
if (nextSegment) {
|
if (nextSegment) {
|
||||||
// Space available until the next segment starts
|
// Space available until the next segment starts
|
||||||
const spaceUntilNextSegment = Math.max(0, nextSegment.startTime - startTime);
|
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
|
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 {
|
} else {
|
||||||
// No next segment, just limited by video duration
|
// No segments at all, use remaining duration
|
||||||
return Math.min(30, remainingDuration);
|
return Math.min(30, remainingDuration);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -1048,9 +1082,6 @@ const TimelineControls = ({
|
|||||||
setClickedTime(newTime);
|
setClickedTime(newTime);
|
||||||
setDisplayTime(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
|
// Find if we clicked in a segment with a small tolerance for boundaries
|
||||||
const segmentAtClickedTime = clipSegments.find(seg => {
|
const segmentAtClickedTime = clipSegments.find(seg => {
|
||||||
// Standard check for being inside a segment
|
// 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
|
// 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')) {
|
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
|
// Check if there's a segment at the clicked position
|
||||||
if (segmentAtClickedTime) {
|
if (segmentAtClickedTime) {
|
||||||
setSelectedSegmentId(segmentAtClickedTime.id);
|
setSelectedSegmentId(segmentAtClickedTime.id);
|
||||||
setShowEmptySpaceTooltip(false);
|
setShowEmptySpaceTooltip(false);
|
||||||
} else {
|
} else {
|
||||||
// First, close segment tooltip if open
|
// We're in a cutaway area - always show tooltip
|
||||||
setSelectedSegmentId(null);
|
setSelectedSegmentId(null);
|
||||||
|
|
||||||
// Calculate the available space for a new segment
|
// Calculate the available space for a new segment
|
||||||
const availableSpace = calculateAvailableSpace(newTime);
|
const availableSpace = calculateAvailableSpace(newTime);
|
||||||
setAvailableSegmentDuration(availableSpace);
|
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
|
// Calculate and set tooltip position correctly for zoomed timeline
|
||||||
let xPos;
|
let xPos;
|
||||||
if (zoomLevel > 1) {
|
if (zoomLevel > 1) {
|
||||||
@ -1191,18 +1145,20 @@ const TimelineControls = ({
|
|||||||
y: rect.top - 10 // Position tooltip above the timeline
|
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);
|
setShowEmptySpaceTooltip(true);
|
||||||
|
|
||||||
// Close tooltip when clicking outside
|
// Log the cutaway area details
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||||
const target = event.target as HTMLElement;
|
const prevSegment = [...sortedSegments].reverse().find(seg => seg.endTime < newTime);
|
||||||
|
const nextSegment = sortedSegments.find(seg => seg.startTime > newTime);
|
||||||
// This is now handled by the global document click handler - just remove this listener
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 }
|
detail: { segmentId }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Hide tooltip during drag
|
// Keep the tooltip visible during drag
|
||||||
setSelectedSegmentId(null);
|
|
||||||
setShowEmptySpaceTooltip(false);
|
|
||||||
|
|
||||||
// Function to handle both mouse and touch movements
|
// Function to handle both mouse and touch movements
|
||||||
const handleDragMove = (clientX: number) => {
|
const handleDragMove = (clientX: number) => {
|
||||||
if (!isDragging || !timelineRef.current) return;
|
if (!isDragging || !timelineRef.current) return;
|
||||||
@ -1262,7 +1215,35 @@ const TimelineControls = ({
|
|||||||
const updatedTimelineRect = timelineRef.current.getBoundingClientRect();
|
const updatedTimelineRect = timelineRef.current.getBoundingClientRect();
|
||||||
const position = Math.max(0, Math.min(1, (clientX - updatedTimelineRect.left) / updatedTimelineRect.width));
|
const position = Math.max(0, Math.min(1, (clientX - updatedTimelineRect.left) / updatedTimelineRect.width));
|
||||||
const newTime = position * duration;
|
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)
|
// Find neighboring segments (exclude the current one)
|
||||||
const otherSegments = clipSegments.filter(seg => seg.id !== segmentId);
|
const otherSegments = clipSegments.filter(seg => seg.id !== segmentId);
|
||||||
|
|
||||||
@ -1400,10 +1381,6 @@ const TimelineControls = ({
|
|||||||
document.body.removeChild(overlay);
|
document.body.removeChild(overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep tooltip hidden after drag
|
|
||||||
setSelectedSegmentId(null);
|
|
||||||
setShowEmptySpaceTooltip(false);
|
|
||||||
|
|
||||||
// Record the final position in history as a single action
|
// Record the final position in history as a single action
|
||||||
const finalSegments = clipSegments.map(seg => {
|
const finalSegments = clipSegments.map(seg => {
|
||||||
if (seg.id === segmentId) {
|
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(() => {
|
useEffect(() => {
|
||||||
// Handle the segment deletion event
|
|
||||||
const handleSegmentDelete = (event: CustomEvent) => {
|
const handleSegmentDelete = (event: CustomEvent) => {
|
||||||
const { segmentId } = event.detail;
|
const { segmentId } = event.detail;
|
||||||
|
|
||||||
// If the deleted segment is the one with the currently open tooltip
|
// Check if this was the last segment before deletion
|
||||||
if (selectedSegmentId === segmentId) {
|
const remainingSegments = clipSegments.filter(seg => seg.id !== segmentId);
|
||||||
const deletedSegmentIndex = clipSegments.findIndex(seg => seg.id === segmentId);
|
if (remainingSegments.length === 0) {
|
||||||
if (deletedSegmentIndex !== -1) {
|
// Create a full video segment
|
||||||
const deletedSegment = clipSegments[deletedSegmentIndex];
|
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
|
setTooltipPosition({
|
||||||
const currentVideoTime = currentTime;
|
x: xPosition,
|
||||||
|
y: rect.top - 10
|
||||||
|
});
|
||||||
|
|
||||||
// Check if the current time was within the deleted segment
|
logger.debug("Created full video segment:", {
|
||||||
const wasInsideDeletedSegment =
|
id: fullVideoSegment.id,
|
||||||
currentVideoTime >= deletedSegment.startTime &&
|
duration: formatDetailedTime(duration),
|
||||||
currentVideoTime <= deletedSegment.endTime;
|
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
|
setTooltipPosition({
|
||||||
const deletedSegmentMiddle = (deletedSegment.startTime + deletedSegment.endTime) / 2;
|
x: xPosition,
|
||||||
const timeToUse = wasInsideDeletedSegment ? currentVideoTime : deletedSegmentMiddle;
|
y: rect.top - 10
|
||||||
|
});
|
||||||
|
|
||||||
// Calculate available space after deletion
|
logger.debug("Segment deleted, showing cutaway tooltip:", {
|
||||||
const availableSpace = calculateAvailableSpace(timeToUse);
|
position: formatDetailedTime(currentTime),
|
||||||
|
availableSpace: formatDetailedTime(availableSpace)
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -1808,7 +1804,7 @@ const TimelineControls = ({
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('delete-segment', handleSegmentDelete as EventListener);
|
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
|
// Add an effect to synchronize tooltip play state with video play state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -1816,8 +1812,124 @@ const TimelineControls = ({
|
|||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
|
||||||
const handlePlay = () => {
|
const handlePlay = () => {
|
||||||
logger.debug("Video started playing from external control");
|
if (!videoRef.current) return;
|
||||||
setIsPlayingSegment(true);
|
|
||||||
|
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 = () => {
|
const handlePause = () => {
|
||||||
@ -1832,7 +1944,7 @@ const TimelineControls = ({
|
|||||||
video.removeEventListener('play', handlePlay);
|
video.removeEventListener('play', handlePlay);
|
||||||
video.removeEventListener('pause', handlePause);
|
video.removeEventListener('pause', handlePause);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [clipSegments, duration, onSeek]);
|
||||||
|
|
||||||
// Handle mouse movement over timeline to remember position
|
// Handle mouse movement over timeline to remember position
|
||||||
const handleTimelineMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleTimelineMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
@ -2248,8 +2360,9 @@ const TimelineControls = ({
|
|||||||
className="timeline-marker"
|
className="timeline-marker"
|
||||||
style={{ left: `${currentTimePercent}%` }}
|
style={{ left: `${currentTimePercent}%` }}
|
||||||
>
|
>
|
||||||
|
{/* Top circle for popup toggle */}
|
||||||
<div
|
<div
|
||||||
className={`timeline-marker-head ${isDragging ? 'dragging' : ''}`}
|
className="timeline-marker-head"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
// Prevent event propagation to avoid triggering the timeline container click
|
// Prevent event propagation to avoid triggering the timeline container click
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -2300,13 +2413,20 @@ const TimelineControls = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMouseDown={startDrag}
|
|
||||||
onTouchStart={startTouchDrag}
|
|
||||||
>
|
>
|
||||||
<span className="timeline-marker-head-icon">
|
<span className="timeline-marker-head-icon">
|
||||||
{selectedSegmentId || showEmptySpaceTooltip ? '-' : '+'}
|
{selectedSegmentId || showEmptySpaceTooltip ? '-' : '+'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Trim Line Markers - hidden when segments exist */}
|
{/* 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'}} />
|
<img src={playFromBeginningIcon} alt="Play from beginning" style={{width: '24px', height: '24px'}} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
{/* <button
|
||||||
className={`tooltip-action-btn ${isPlayingSegment ? 'pause' : 'play'}`}
|
className={`tooltip-action-btn ${isPlaying ? 'pause' : 'play'}`}
|
||||||
data-tooltip={isPlayingSegment ? "Pause playback" : "Play from current position"}
|
data-tooltip={isPlaying ? "Pause playback" : "Play from current position"}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
// Find the selected segment
|
// Find the current segment
|
||||||
const segment = clipSegments.find(seg => seg.id === selectedSegmentId);
|
const currentSegment = clipSegments.find(seg =>
|
||||||
if (segment && videoRef.current) {
|
currentTime >= seg.startTime && currentTime <= seg.endTime
|
||||||
if (isPlayingSegment) {
|
);
|
||||||
// If already playing, pause the video
|
|
||||||
|
if (isPlaying) {
|
||||||
|
// If playing, just pause
|
||||||
|
if (videoRef.current) {
|
||||||
videoRef.current.pause();
|
videoRef.current.pause();
|
||||||
setIsPlayingSegment(false);
|
setIsPlayingSegment(false);
|
||||||
// Reset continuePastBoundary when stopping playback
|
|
||||||
setContinuePastBoundary(false);
|
setContinuePastBoundary(false);
|
||||||
logger.debug("Pause clicked - resetting continuePastBoundary flag");
|
}
|
||||||
} else {
|
} else {
|
||||||
// Enable continuePastBoundary flag when user explicitly clicks play
|
// If starting playback, set the active segment
|
||||||
// This will allow playback to continue even if we're at segment boundary
|
if (currentSegment) {
|
||||||
setContinuePastBoundary(true);
|
setActiveSegment(currentSegment);
|
||||||
logger.debug("Setting continuePastBoundary=true to allow playback through boundaries");
|
}
|
||||||
|
|
||||||
// Keep current position (use the current time marker) and just start playing
|
// Reset continuation flag when starting new playback
|
||||||
// Don't seek to segment start - this allows continuing from where the marker is
|
setContinuePastBoundary(false);
|
||||||
logger.debug("Play from current position - initial time:", formatDetailedTime(videoRef.current.currentTime));
|
|
||||||
|
if (videoRef.current) {
|
||||||
// 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
|
|
||||||
videoRef.current.play()
|
videoRef.current.play()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setIsPlayingSegment(true);
|
setIsPlayingSegment(true);
|
||||||
logger.debug("Play clicked - continuing from current position:", formatDetailedTime(videoRef.current?.currentTime || 0));
|
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.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={pauseIcon} alt="Pause" style={{width: '24px', height: '24px'}} />
|
||||||
) : (
|
) : (
|
||||||
<img src={playIcon} alt="Play" style={{width: '24px', height: '24px'}} />
|
<img src={playIcon} alt="Play" style={{width: '24px', height: '24px'}} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="tooltip-action-btn set-in"
|
className="tooltip-action-btn set-in"
|
||||||
data-tooltip="Set start point at current position"
|
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'}} />
|
<img src={playFromBeginningIcon} alt="Play from beginning" style={{width: '24px', height: '24px'}} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
||||||
{/* Play/Pause button for empty space */}
|
{/* Play/Pause button for empty space */}
|
||||||
<button
|
{/* <button
|
||||||
className={`tooltip-action-btn ${isPlayingSegment ? 'pause' : 'play'}`}
|
className={`tooltip-action-btn ${isPlaying ? 'pause' : 'play'}`}
|
||||||
data-tooltip={isPlayingSegment ? "Pause playback" : "Play from here until next segment"}
|
data-tooltip={isPlaying ? "Pause playback" : "Play from here until next segment"}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
if (videoRef.current) {
|
if (videoRef.current) {
|
||||||
if (isPlayingSegment) {
|
if (isPlaying) {
|
||||||
// If already playing, pause the video
|
// If already playing, pause the video
|
||||||
videoRef.current.pause();
|
videoRef.current.pause();
|
||||||
setIsPlayingSegment(false);
|
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={pauseIcon} alt="Pause" style={{width: '24px', height: '24px'}} />
|
||||||
) : (
|
) : (
|
||||||
<img src={playIcon} alt="Play" 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 { formatTime, formatDetailedTime } from "@/lib/timeUtils";
|
||||||
|
import logger from '../lib/logger';
|
||||||
import '../styles/VideoPlayer.css';
|
import '../styles/VideoPlayer.css';
|
||||||
|
|
||||||
interface VideoPlayerProps {
|
interface VideoPlayerProps {
|
||||||
@ -13,7 +14,7 @@ interface VideoPlayerProps {
|
|||||||
onToggleMute?: () => void;
|
onToggleMute?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VideoPlayer = ({
|
const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||||
videoRef,
|
videoRef,
|
||||||
currentTime,
|
currentTime,
|
||||||
duration,
|
duration,
|
||||||
@ -22,7 +23,7 @@ const VideoPlayer = ({
|
|||||||
onPlayPause,
|
onPlayPause,
|
||||||
onSeek,
|
onSeek,
|
||||||
onToggleMute
|
onToggleMute
|
||||||
}: VideoPlayerProps) => {
|
}) => {
|
||||||
const progressRef = useRef<HTMLDivElement>(null);
|
const progressRef = useRef<HTMLDivElement>(null);
|
||||||
const [isIOS, setIsIOS] = useState(false);
|
const [isIOS, setIsIOS] = useState(false);
|
||||||
const [hasInitialized, setHasInitialized] = useState(false);
|
const [hasInitialized, setHasInitialized] = useState(false);
|
||||||
@ -34,7 +35,7 @@ const VideoPlayer = ({
|
|||||||
|
|
||||||
const sampleVideoUrl = typeof window !== 'undefined' &&
|
const sampleVideoUrl = typeof window !== 'undefined' &&
|
||||||
(window as any).MEDIA_DATA?.videoUrl ||
|
(window as any).MEDIA_DATA?.videoUrl ||
|
||||||
"https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
|
"/videos/sample-video-30s.mp4";
|
||||||
|
|
||||||
// Detect iOS device
|
// Detect iOS device
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -64,14 +65,48 @@ const VideoPlayer = ({
|
|||||||
|
|
||||||
// Add iOS-specific attributes to prevent fullscreen playback
|
// Add iOS-specific attributes to prevent fullscreen playback
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (videoRef.current) {
|
const video = videoRef.current;
|
||||||
// These attributes need to be set directly on the DOM element
|
if (!video) return;
|
||||||
// for iOS Safari to respect inline playback
|
|
||||||
videoRef.current.setAttribute('playsinline', 'true');
|
// These attributes need to be set directly on the DOM element
|
||||||
videoRef.current.setAttribute('webkit-playsinline', 'true');
|
// for iOS Safari to respect inline playback
|
||||||
videoRef.current.setAttribute('x-webkit-airplay', 'allow');
|
video.setAttribute('playsinline', 'true');
|
||||||
}
|
video.setAttribute('webkit-playsinline', 'true');
|
||||||
}, [videoRef]);
|
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)
|
// Save current time to lastPosition when it changes (from external seeking)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -248,23 +283,19 @@ const VideoPlayer = ({
|
|||||||
if (video.paused) {
|
if (video.paused) {
|
||||||
// For iOS Safari: Before playing, explicitly seek to the remembered position
|
// For iOS Safari: Before playing, explicitly seek to the remembered position
|
||||||
if (isIOS && lastPosition !== null && lastPosition > 0) {
|
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
|
// First, seek to the position
|
||||||
video.currentTime = lastPosition;
|
video.currentTime = lastPosition;
|
||||||
|
|
||||||
// Use a small timeout to ensure seeking is complete before play
|
// Use a small timeout to ensure seeking is complete before play
|
||||||
// This is critical for iOS Safari
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (videoRef.current) {
|
if (videoRef.current) {
|
||||||
// Try to play with proper promise handling
|
// Try to play with proper promise handling
|
||||||
videoRef.current.play()
|
videoRef.current.play()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log("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
|
||||||
// Mark as initialized
|
|
||||||
setHasInitialized(true);
|
|
||||||
localStorage.setItem('video_initialized', 'true');
|
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error("iOS: Error playing video:", err);
|
console.error("iOS: Error playing video:", err);
|
||||||
@ -275,19 +306,18 @@ const VideoPlayer = ({
|
|||||||
// Normal play (non-iOS or no remembered position)
|
// Normal play (non-iOS or no remembered position)
|
||||||
video.play()
|
video.play()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log("Normal: Play started successfully");
|
logger.debug("Normal: Play started successfully");
|
||||||
|
onPlayPause(); // Update parent state after successful play
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error("Error playing video:", err);
|
console.error("Error playing video:", err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If playing, just pause
|
// If playing, pause and update state
|
||||||
video.pause();
|
video.pause();
|
||||||
|
onPlayPause();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the parent component's onPlayPause to update state
|
|
||||||
onPlayPause();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -145,6 +145,7 @@ const useVideoTrimmer = () => {
|
|||||||
// Only update isPlaying if we're not in preview mode
|
// Only update isPlaying if we're not in preview mode
|
||||||
if (!isPreviewMode) {
|
if (!isPreviewMode) {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
|
setVideoInitialized(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -932,11 +933,27 @@ const useVideoTrimmer = () => {
|
|||||||
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
|
||||||
|
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 {
|
return {
|
||||||
videoRef,
|
videoRef,
|
||||||
currentTime,
|
currentTime,
|
||||||
duration,
|
duration,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
|
setIsPlaying,
|
||||||
|
isMuted,
|
||||||
isPreviewMode,
|
isPreviewMode,
|
||||||
thumbnails,
|
thumbnails,
|
||||||
trimStart,
|
trimStart,
|
||||||
@ -944,25 +961,25 @@ const useVideoTrimmer = () => {
|
|||||||
splitPoints,
|
splitPoints,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
clipSegments,
|
clipSegments,
|
||||||
|
hasUnsavedChanges,
|
||||||
historyPosition,
|
historyPosition,
|
||||||
history,
|
history,
|
||||||
isMuted,
|
|
||||||
hasUnsavedChanges, // Add unsaved changes flag to the return object
|
|
||||||
playPauseVideo,
|
|
||||||
seekVideo,
|
|
||||||
handleTrimStartChange,
|
handleTrimStartChange,
|
||||||
handleTrimEndChange,
|
handleTrimEndChange,
|
||||||
|
handleZoomChange,
|
||||||
|
handleMobileSafeSeek,
|
||||||
handleSplit,
|
handleSplit,
|
||||||
handleReset,
|
handleReset,
|
||||||
handleUndo,
|
handleUndo,
|
||||||
handleRedo,
|
handleRedo,
|
||||||
handlePreview,
|
handlePreview,
|
||||||
handlePlay,
|
|
||||||
handleZoomChange,
|
|
||||||
toggleMute,
|
toggleMute,
|
||||||
handleSave,
|
handleSave,
|
||||||
handleSaveACopy,
|
handleSaveACopy,
|
||||||
handleSaveSegments,
|
handleSaveSegments,
|
||||||
|
isMobile,
|
||||||
|
videoInitialized,
|
||||||
|
setVideoInitialized,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -65,7 +65,7 @@
|
|||||||
background-color: white;
|
background-color: white;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 2.5rem;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -56,31 +56,51 @@
|
|||||||
|
|
||||||
.timeline-marker {
|
.timeline-marker {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
height: 82px; /* Increased height to extend below timeline */
|
||||||
bottom: 0;
|
width: 2px;
|
||||||
width: 1px;
|
background-color: #000;
|
||||||
background-color: red;
|
transform: translateX(-50%);
|
||||||
z-index: 30;
|
z-index: 50;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-marker-head {
|
.timeline-marker-head {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -11px;
|
top: -6px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
width: 20px;
|
width: 16px;
|
||||||
height: 20px;
|
height: 16px;
|
||||||
background-color: red;
|
background-color: #ef4444;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
pointer-events: auto;
|
cursor: pointer;
|
||||||
cursor: grab;
|
|
||||||
z-index: 31;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: transform 0.1s ease, background-color 0.1s ease;
|
pointer-events: auto;
|
||||||
touch-action: none;
|
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 {
|
.timeline-marker-head-icon {
|
||||||
@ -88,13 +108,16 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-marker-head.dragging {
|
.timeline-marker-drag-icon {
|
||||||
transform: translateX(-50%) scale(1.2);
|
color: white;
|
||||||
cursor: grabbing;
|
font-size: 12px;
|
||||||
background-color: #ff3333;
|
line-height: 1;
|
||||||
box-shadow: 0 0 8px rgba(255, 0, 0, 0.6);
|
user-select: none;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trim-line-marker {
|
.trim-line-marker {
|
||||||
@ -258,27 +281,27 @@
|
|||||||
background-color: rgba(0, 0, 0, 0.6);
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timeline-marker {
|
||||||
|
height: 52px; /* Increased height for touch devices */
|
||||||
|
}
|
||||||
|
|
||||||
.timeline-marker-head {
|
.timeline-marker-head {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
top: -13px;
|
top: -13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timeline-marker-drag {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
bottom: -18px; /* Adjusted for larger touch target */
|
||||||
|
}
|
||||||
|
|
||||||
.timeline-marker-head.dragging {
|
.timeline-marker-head.dragging {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
top: -15px;
|
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,
|
.segment-tooltip,
|
||||||
@ -292,7 +315,8 @@
|
|||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
top: -90px !important;
|
top: -100px !important;
|
||||||
|
transform: translateY(-10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.segment-tooltip:after,
|
.segment-tooltip:after,
|
||||||
@ -309,6 +333,21 @@
|
|||||||
border-top: 5px solid white;
|
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 {
|
.tooltip-time {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
|||||||
@ -61,17 +61,36 @@
|
|||||||
}
|
}
|
||||||
.video-player-container {
|
.video-player-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: black;
|
width: 100%;
|
||||||
|
background: #000;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
aspect-ratio: 16/9;
|
aspect-ratio: 16/9;
|
||||||
|
/* Prevent iOS Safari from showing default video controls */
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-player-container video {
|
.video-player-container video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
cursor: pointer;
|
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 {
|
.play-pause-indicator {
|
||||||
@ -85,36 +104,67 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.3s;
|
transition: opacity 0.3s;
|
||||||
|
pointer-events: none;
|
||||||
&::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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-player-container:hover .play-pause-indicator {
|
.video-player-container:hover .play-pause-indicator {
|
||||||
opacity: 1;
|
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 {
|
.video-controls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|||||||
@ -25,7 +25,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-sidebar {
|
.page-sidebar {
|
||||||
z-index: +6;
|
z-index: +20;
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
z-index: +5;
|
z-index: +5;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user