Yiannis Christodoulou 2025-05-21 06:16:35 +03:00
parent 0c75e2503c
commit 347ce9af6f
12 changed files with 798 additions and 504 deletions

View File

@ -5,3 +5,4 @@ server/public
vite.config.ts.*
*.tar.gz
yt.readme.md
client/public/videos/sample-video.mp4

View File

@ -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));
// If already playing, just pause the video
if (isPlaying) {
video.pause();
setIsPlaying(false);
return;
}
// Setup continuous adjustment
incrementIntervalRef.current = setInterval(() => {
const currentVideoTime = videoRef.current?.currentTime || 0;
const newTime = Math.min(duration, currentVideoTime + 0.05);
seekVideo(newTime);
}, 100);
};
const currentPosition = Number(video.currentTime.toFixed(6)); // Fix to microsecond precision
// Stop continuous increment
const stopIncrement = () => {
if (incrementIntervalRef.current) {
clearInterval(incrementIntervalRef.current);
incrementIntervalRef.current = null;
// Find the next stopping point based on current position
let stopTime = duration;
let currentSegment = null;
let nextSegment = null;
// Sort segments by start time to ensure correct order
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
// First, check if we're inside a segment or exactly at its start/end
currentSegment = sortedSegments.find(seg => {
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;
});
}
// 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);
}
};
// Start continuous 50ms decrement when button is held
const startDecrement = (e: React.MouseEvent | React.TouchEvent) => {
// Prevent default to avoid text selection
e.preventDefault();
// 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
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
// Remove our boundary checker
video.removeEventListener('timeupdate', checkBoundary);
setIsPlaying(false);
// First immediate adjustment
seekVideo(Math.max(0, currentTime - 0.05));
// 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
});
// 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;
return;
}
};
// 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);
}
// 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 */}

View File

@ -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>

View File

@ -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]);

View File

@ -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;
// Remove the global click handler that closes tooltips
// 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);
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;
// 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);
// 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 }
}));
// 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;
@ -1263,6 +1216,34 @@ const TimelineControls = ({
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) {
@ -1739,43 +1716,42 @@ const TimelineControls = ({
// Add a new useEffect hook to listen for segment deletion events
useEffect(() => {
// Handle the segment deletion event
const handleSegmentDelete = (event: CustomEvent) => {
const { segmentId } = event.detail;
// If the deleted segment is the one with the currently open tooltip
if (selectedSegmentId === segmentId) {
const deletedSegmentIndex = clipSegments.findIndex(seg => seg.id === segmentId);
if (deletedSegmentIndex !== -1) {
const deletedSegment = clipSegments[deletedSegmentIndex];
// 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: ''
};
// We need the current time to check if we should show the cutaway tooltip
const currentVideoTime = currentTime;
// 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);
// Check if the current time was within the deleted segment
const wasInsideDeletedSegment =
currentVideoTime >= deletedSegment.startTime &&
currentVideoTime <= deletedSegment.endTime;
// Update UI to show the segment tooltip
setSelectedSegmentId(fullVideoSegment.id);
setShowEmptySpaceTooltip(false);
setClickedTime(currentTime);
setDisplayTime(currentTime);
setActiveSegment(fullVideoSegment);
// Calculate position in the middle of the deleted segment for tooltip
const deletedSegmentMiddle = (deletedSegment.startTime + deletedSegment.endTime) / 2;
const timeToUse = wasInsideDeletedSegment ? currentVideoTime : deletedSegmentMiddle;
// Calculate available space after deletion
const availableSpace = calculateAvailableSpace(timeToUse);
// Update UI to show cutaway tooltip in place of segment tooltip
setSelectedSegmentId(null);
if (availableSpace >= 0.5) {
// Set the time for the tooltip
setClickedTime(timeToUse);
setDisplayTime(timeToUse);
// Calculate tooltip position
// Calculate tooltip position at current time
if (timelineRef.current) {
const rect = timelineRef.current.getBoundingClientRect();
const posPercent = (timeToUse / duration) * 100;
const posPercent = (currentTime / duration) * 100;
const xPosition = rect.left + (rect.width * (posPercent / 100));
setTooltipPosition({
@ -1783,20 +1759,40 @@ const TimelineControls = ({
y: rect.top - 10
});
// Show the empty space tooltip
setAvailableSegmentDuration(availableSpace);
setShowEmptySpaceTooltip(true);
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;
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);
}
// 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));
setTooltipPosition({
x: xPosition,
y: rect.top - 10
});
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");
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
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)
// Find the current segment
const currentSegment = clipSegments.find(seg =>
currentTime >= seg.startTime && currentTime <= seg.endTime
);
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");
if (isPlaying) {
// If playing, just pause
if (videoRef.current) {
videoRef.current.pause();
setIsPlayingSegment(false);
setContinuePastBoundary(false);
}
} 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");
// If starting playback, set the active segment
if (currentSegment) {
setActiveSegment(currentSegment);
}
// Set active segment for boundary checking
setActiveSegment(segment);
logger.debug("Set active segment for boundary checking:", segment.id);
}
// Reset continuation flag when starting new playback
setContinuePastBoundary(false);
// Play the video from the current position
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"
@ -2980,15 +3043,16 @@ 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'}} />

View File

@ -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) {
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
videoRef.current.setAttribute('playsinline', 'true');
videoRef.current.setAttribute('webkit-playsinline', 'true');
videoRef.current.setAttribute('x-webkit-airplay', 'allow');
video.setAttribute('playsinline', 'true');
video.setAttribute('webkit-playsinline', 'true');
video.setAttribute('x-webkit-airplay', 'allow');
// Store the last known good position for iOS
const handleTimeUpdate = () => {
if (!isDraggingProgressRef.current) {
setLastPosition(video.currentTime);
if (typeof window !== 'undefined') {
window.lastSeekedPosition = video.currentTime;
}
}, [videoRef]);
}
};
// 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();
}
// Call the parent component's onPlayPause to update state
onPlayPause();
}
};
return (

View File

@ -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,
};
};

View File

@ -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);
}

View File

@ -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;

View File

@ -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,8 +104,14 @@
border-radius: 50%;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
&::before {
.video-player-container:hover .play-pause-indicator {
opacity: 1;
}
.play-pause-indicator::before {
content: '';
position: absolute;
top: 50%;
@ -94,7 +119,7 @@
transform: translate(-50%, -50%);
}
&.play-icon::before {
.play-pause-indicator.play-icon::before {
width: 0;
height: 0;
border-top: 15px solid transparent;
@ -103,16 +128,41 @@
margin-left: 3px;
}
&.pause-icon::before {
.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;
}
.video-player-container:hover .play-pause-indicator {
opacity: 1;
.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 {

View File

@ -25,7 +25,7 @@
}
.page-sidebar {
z-index: +6;
z-index: +20;
@media (min-width: 768px) {
z-index: +5;