mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-11-05 23:18:53 -05:00
style: Format entire codebase (video-editor) with Prettier
This commit is contained in:
parent
e101a48c48
commit
add6f6a704
@ -41,7 +41,7 @@ const App = () => {
|
||||
videoInitialized,
|
||||
setVideoInitialized,
|
||||
isPlayingSegments,
|
||||
handlePlaySegments,
|
||||
handlePlaySegments
|
||||
} = useVideoTrimmer();
|
||||
|
||||
// Function to play from the beginning
|
||||
@ -69,31 +69,31 @@ const App = () => {
|
||||
|
||||
const handlePlay = () => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
|
||||
const video = videoRef.current;
|
||||
|
||||
|
||||
// If already playing, just pause the video
|
||||
if (isPlaying) {
|
||||
video.pause();
|
||||
setIsPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const currentPosition = Number(video.currentTime.toFixed(6)); // Fix to microsecond precision
|
||||
|
||||
|
||||
// Find the next stopping point based on current position
|
||||
let stopTime = duration;
|
||||
let currentSegment = null;
|
||||
let nextSegment = null;
|
||||
|
||||
|
||||
// Sort segments by start time to ensure correct order
|
||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
|
||||
// First, check if we're inside a segment or exactly at its start/end
|
||||
currentSegment = sortedSegments.find(seg => {
|
||||
currentSegment = sortedSegments.find((seg) => {
|
||||
const segStartTime = Number(seg.startTime.toFixed(6));
|
||||
const segEndTime = Number(seg.endTime.toFixed(6));
|
||||
|
||||
|
||||
// Check if we're inside the segment
|
||||
if (currentPosition > segStartTime && currentPosition < segEndTime) {
|
||||
return true;
|
||||
@ -109,15 +109,15 @@ const App = () => {
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
|
||||
// If we're not in a segment, find the next segment
|
||||
if (!currentSegment) {
|
||||
nextSegment = sortedSegments.find(seg => {
|
||||
nextSegment = sortedSegments.find((seg) => {
|
||||
const segStartTime = Number(seg.startTime.toFixed(6));
|
||||
return segStartTime > currentPosition;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Determine where to stop based on position
|
||||
if (currentSegment) {
|
||||
// If we're in a segment, stop at its end
|
||||
@ -126,113 +126,123 @@ const App = () => {
|
||||
// If we're in a cutaway and there's a next segment, stop at its start
|
||||
stopTime = Number(nextSegment.startTime.toFixed(6));
|
||||
}
|
||||
|
||||
|
||||
// Create a boundary checker function with high precision
|
||||
const checkBoundary = () => {
|
||||
if (!video) return;
|
||||
|
||||
|
||||
const currentPosition = Number(video.currentTime.toFixed(6));
|
||||
const timeLeft = Number((stopTime - currentPosition).toFixed(6));
|
||||
|
||||
|
||||
// If we've reached or passed the boundary
|
||||
if (timeLeft <= 0 || currentPosition >= stopTime) {
|
||||
// First pause playback
|
||||
video.pause();
|
||||
|
||||
|
||||
// Force exact position with multiple verification attempts
|
||||
const setExactPosition = () => {
|
||||
if (!video) return;
|
||||
|
||||
|
||||
// Set to exact boundary time
|
||||
video.currentTime = stopTime;
|
||||
handleMobileSafeSeek(stopTime);
|
||||
|
||||
|
||||
const actualPosition = Number(video.currentTime.toFixed(6));
|
||||
const difference = Number(Math.abs(actualPosition - stopTime).toFixed(6));
|
||||
|
||||
|
||||
logger.debug("Position verification:", {
|
||||
target: formatDetailedTime(stopTime),
|
||||
actual: formatDetailedTime(actualPosition),
|
||||
difference: difference
|
||||
});
|
||||
|
||||
|
||||
// If we're not exactly at the target position, try one more time
|
||||
if (difference > 0) {
|
||||
video.currentTime = stopTime;
|
||||
handleMobileSafeSeek(stopTime);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Multiple attempts to ensure precision, with increasing delays
|
||||
setExactPosition();
|
||||
setTimeout(setExactPosition, 5); // Quick first retry
|
||||
setTimeout(setExactPosition, 5); // Quick first retry
|
||||
setTimeout(setExactPosition, 10); // Second retry
|
||||
setTimeout(setExactPosition, 20); // Third retry if needed
|
||||
setTimeout(setExactPosition, 50); // Final verification
|
||||
|
||||
|
||||
// Remove our boundary checker
|
||||
video.removeEventListener('timeupdate', checkBoundary);
|
||||
video.removeEventListener("timeupdate", checkBoundary);
|
||||
setIsPlaying(false);
|
||||
|
||||
|
||||
// Log the final position for debugging
|
||||
logger.debug("Stopped at position:", {
|
||||
target: formatDetailedTime(stopTime),
|
||||
actual: formatDetailedTime(video.currentTime),
|
||||
type: currentSegment ? "segment end" : (nextSegment ? "next segment start" : "end of video"),
|
||||
segment: currentSegment ? {
|
||||
id: currentSegment.id,
|
||||
start: formatDetailedTime(currentSegment.startTime),
|
||||
end: formatDetailedTime(currentSegment.endTime)
|
||||
} : null,
|
||||
nextSegment: nextSegment ? {
|
||||
id: nextSegment.id,
|
||||
start: formatDetailedTime(nextSegment.startTime),
|
||||
end: formatDetailedTime(nextSegment.endTime)
|
||||
} : null
|
||||
type: currentSegment
|
||||
? "segment end"
|
||||
: nextSegment
|
||||
? "next segment start"
|
||||
: "end of video",
|
||||
segment: currentSegment
|
||||
? {
|
||||
id: currentSegment.id,
|
||||
start: formatDetailedTime(currentSegment.startTime),
|
||||
end: formatDetailedTime(currentSegment.endTime)
|
||||
}
|
||||
: null,
|
||||
nextSegment: nextSegment
|
||||
? {
|
||||
id: nextSegment.id,
|
||||
start: formatDetailedTime(nextSegment.startTime),
|
||||
end: formatDetailedTime(nextSegment.endTime)
|
||||
}
|
||||
: null
|
||||
});
|
||||
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Start our boundary checker
|
||||
video.addEventListener('timeupdate', checkBoundary);
|
||||
|
||||
video.addEventListener("timeupdate", checkBoundary);
|
||||
|
||||
// Start playing
|
||||
video.play()
|
||||
video
|
||||
.play()
|
||||
.then(() => {
|
||||
setIsPlaying(true);
|
||||
setVideoInitialized(true);
|
||||
logger.debug("Playback started:", {
|
||||
from: formatDetailedTime(currentPosition),
|
||||
to: formatDetailedTime(stopTime),
|
||||
currentSegment: currentSegment ? {
|
||||
id: currentSegment.id,
|
||||
start: formatDetailedTime(currentSegment.startTime),
|
||||
end: formatDetailedTime(currentSegment.endTime)
|
||||
} : 'None',
|
||||
nextSegment: nextSegment ? {
|
||||
id: nextSegment.id,
|
||||
start: formatDetailedTime(nextSegment.startTime),
|
||||
end: formatDetailedTime(nextSegment.endTime)
|
||||
} : 'None'
|
||||
currentSegment: currentSegment
|
||||
? {
|
||||
id: currentSegment.id,
|
||||
start: formatDetailedTime(currentSegment.startTime),
|
||||
end: formatDetailedTime(currentSegment.endTime)
|
||||
}
|
||||
: "None",
|
||||
nextSegment: nextSegment
|
||||
? {
|
||||
id: nextSegment.id,
|
||||
start: formatDetailedTime(nextSegment.startTime),
|
||||
end: formatDetailedTime(nextSegment.endTime)
|
||||
}
|
||||
: "None"
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
.catch((err) => {
|
||||
console.error("Error playing video:", err);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background min-h-screen">
|
||||
<MobilePlayPrompt
|
||||
videoRef={videoRef}
|
||||
onPlay={handlePlay}
|
||||
/>
|
||||
|
||||
<MobilePlayPrompt videoRef={videoRef} onPlay={handlePlay} />
|
||||
|
||||
<div className="container mx-auto px-4 py-6 max-w-6xl">
|
||||
{/* Video Player */}
|
||||
<VideoPlayer
|
||||
<VideoPlayer
|
||||
videoRef={videoRef}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
@ -244,7 +254,7 @@ const App = () => {
|
||||
/>
|
||||
|
||||
{/* Editing Tools */}
|
||||
<EditingTools
|
||||
<EditingTools
|
||||
onSplit={handleSplit}
|
||||
onReset={handleReset}
|
||||
onUndo={handleUndo}
|
||||
@ -258,7 +268,7 @@ const App = () => {
|
||||
/>
|
||||
|
||||
{/* Timeline Controls */}
|
||||
<TimelineControls
|
||||
<TimelineControls
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
thumbnails={thumbnails}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { formatTime, formatLongTime } from "@/lib/timeUtils";
|
||||
import '../styles/ClipSegments.css';
|
||||
import "../styles/ClipSegments.css";
|
||||
|
||||
export interface Segment {
|
||||
id: number;
|
||||
@ -16,41 +16,36 @@ interface ClipSegmentsProps {
|
||||
const ClipSegments = ({ segments }: ClipSegmentsProps) => {
|
||||
// Sort segments by startTime
|
||||
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
|
||||
// Handle delete segment click
|
||||
const handleDeleteSegment = (segmentId: number) => {
|
||||
// Create and dispatch the delete event
|
||||
const deleteEvent = new CustomEvent('delete-segment', {
|
||||
detail: { segmentId }
|
||||
const deleteEvent = new CustomEvent("delete-segment", {
|
||||
detail: { segmentId }
|
||||
});
|
||||
document.dispatchEvent(deleteEvent);
|
||||
};
|
||||
|
||||
|
||||
// Generate the same color background for a segment as shown in the timeline
|
||||
const getSegmentColorClass = (index: number) => {
|
||||
// Return CSS class based on index modulo 8
|
||||
// Return CSS class based on index modulo 8
|
||||
// This matches the CSS nth-child selectors in the timeline
|
||||
return `segment-default-color segment-color-${(index % 8) + 1}`;
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="clip-segments-container">
|
||||
<h3 className="clip-segments-title">Clip Segments</h3>
|
||||
|
||||
|
||||
{sortedSegments.map((segment, index) => (
|
||||
<div
|
||||
key={segment.id}
|
||||
className={`segment-item ${getSegmentColorClass(index)}`}
|
||||
>
|
||||
<div key={segment.id} className={`segment-item ${getSegmentColorClass(index)}`}>
|
||||
<div className="segment-content">
|
||||
<div
|
||||
className="segment-thumbnail"
|
||||
<div
|
||||
className="segment-thumbnail"
|
||||
style={{ backgroundImage: `url(${segment.thumbnail})` }}
|
||||
></div>
|
||||
<div className="segment-info">
|
||||
<div className="segment-title">
|
||||
Segment {index + 1}
|
||||
</div>
|
||||
<div className="segment-title">Segment {index + 1}</div>
|
||||
<div className="segment-time">
|
||||
{formatTime(segment.startTime)} - {formatTime(segment.endTime)}
|
||||
</div>
|
||||
@ -60,20 +55,24 @@ const ClipSegments = ({ segments }: ClipSegmentsProps) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="segment-actions">
|
||||
<button
|
||||
className="delete-button"
|
||||
<button
|
||||
className="delete-button"
|
||||
aria-label="Delete Segment"
|
||||
data-tooltip="Delete this segment"
|
||||
onClick={() => handleDeleteSegment(segment.id)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
{sortedSegments.length === 0 && (
|
||||
<div className="empty-message">
|
||||
No segments created yet. Use the split button to create segments.
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import '../styles/IOSPlayPrompt.css';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import "../styles/IOSPlayPrompt.css";
|
||||
|
||||
interface MobilePlayPromptProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
@ -13,7 +13,9 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
|
||||
useEffect(() => {
|
||||
const checkIsMobile = () => {
|
||||
// More comprehensive check for mobile/tablet devices
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(navigator.userAgent);
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(
|
||||
navigator.userAgent
|
||||
);
|
||||
};
|
||||
|
||||
// Always show for mobile devices on each visit
|
||||
@ -31,9 +33,9 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
video.addEventListener('play', handlePlay);
|
||||
video.addEventListener("play", handlePlay);
|
||||
return () => {
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener("play", handlePlay);
|
||||
};
|
||||
}, [videoRef]);
|
||||
|
||||
@ -62,11 +64,8 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
|
||||
<li>Then you'll be able to use all timeline controls</li>
|
||||
</ol>
|
||||
</div> */}
|
||||
|
||||
<button
|
||||
className="mobile-play-button"
|
||||
onClick={handlePlayClick}
|
||||
>
|
||||
|
||||
<button className="mobile-play-button" onClick={handlePlayClick}>
|
||||
Click to start editing...
|
||||
</button>
|
||||
</div>
|
||||
@ -74,4 +73,4 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
|
||||
);
|
||||
};
|
||||
|
||||
export default MobilePlayPrompt;
|
||||
export default MobilePlayPrompt;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { formatTime } from "@/lib/timeUtils";
|
||||
import '../styles/IOSVideoPlayer.css';
|
||||
import "../styles/IOSVideoPlayer.css";
|
||||
|
||||
interface IOSVideoPlayerProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
@ -8,14 +8,10 @@ interface IOSVideoPlayerProps {
|
||||
duration: number;
|
||||
}
|
||||
|
||||
const IOSVideoPlayer = ({
|
||||
videoRef,
|
||||
currentTime,
|
||||
duration,
|
||||
}: IOSVideoPlayerProps) => {
|
||||
const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => {
|
||||
const [videoUrl, setVideoUrl] = useState<string>("");
|
||||
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
|
||||
|
||||
|
||||
// Refs for hold-to-continue functionality
|
||||
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const decrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@ -27,11 +23,11 @@ const IOSVideoPlayer = ({
|
||||
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
// Get the video source URL from the main player
|
||||
useEffect(() => {
|
||||
if (videoRef.current && videoRef.current.querySelector('source')) {
|
||||
const source = videoRef.current.querySelector('source') as HTMLSourceElement;
|
||||
if (videoRef.current && videoRef.current.querySelector("source")) {
|
||||
const source = videoRef.current.querySelector("source") as HTMLSourceElement;
|
||||
if (source && source.src) {
|
||||
setVideoUrl(source.src);
|
||||
}
|
||||
@ -61,13 +57,13 @@ const IOSVideoPlayer = ({
|
||||
const startIncrement = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
// Prevent default to avoid text selection
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
if (!iosVideoRef) return;
|
||||
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
|
||||
|
||||
|
||||
// First immediate adjustment
|
||||
iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05);
|
||||
|
||||
|
||||
// Setup continuous adjustment
|
||||
incrementIntervalRef.current = setInterval(() => {
|
||||
if (iosVideoRef) {
|
||||
@ -88,13 +84,13 @@ const IOSVideoPlayer = ({
|
||||
const startDecrement = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
// Prevent default to avoid text selection
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
if (!iosVideoRef) return;
|
||||
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
|
||||
|
||||
|
||||
// First immediate adjustment
|
||||
iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
|
||||
|
||||
|
||||
// Setup continuous adjustment
|
||||
decrementIntervalRef.current = setInterval(() => {
|
||||
if (iosVideoRef) {
|
||||
@ -115,12 +111,14 @@ const IOSVideoPlayer = ({
|
||||
<div className="ios-video-player-container">
|
||||
{/* Current Time / Duration Display */}
|
||||
<div className="ios-time-display mb-2">
|
||||
<span className="text-sm">{formatTime(currentTime)} / {formatTime(duration)}</span>
|
||||
<span className="text-sm">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
{/* iOS-optimized Video Element with Native Controls */}
|
||||
<video
|
||||
ref={ref => setIosVideoRef(ref)}
|
||||
ref={(ref) => setIosVideoRef(ref)}
|
||||
className="w-full rounded-md"
|
||||
src={videoUrl}
|
||||
controls
|
||||
@ -133,26 +131,26 @@ const IOSVideoPlayer = ({
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
<p>Your browser doesn't support HTML5 video.</p>
|
||||
</video>
|
||||
|
||||
|
||||
{/* iOS Video Skip Controls */}
|
||||
<div className="ios-skip-controls mt-3 flex justify-center gap-4">
|
||||
<button
|
||||
<button
|
||||
onClick={jumpBackward15}
|
||||
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
|
||||
>
|
||||
-15s
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
onClick={jumpForward15}
|
||||
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
|
||||
>
|
||||
+15s
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
{/* iOS Fine Control Buttons */}
|
||||
<div className="ios-fine-controls mt-2 flex justify-center gap-4">
|
||||
<button
|
||||
<button
|
||||
onMouseDown={startDecrement}
|
||||
onTouchStart={startDecrement}
|
||||
onMouseUp={stopDecrement}
|
||||
@ -163,7 +161,7 @@ const IOSVideoPlayer = ({
|
||||
>
|
||||
-50ms
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
onMouseDown={startIncrement}
|
||||
onTouchStart={startIncrement}
|
||||
onMouseUp={stopIncrement}
|
||||
@ -175,7 +173,7 @@ const IOSVideoPlayer = ({
|
||||
+50ms
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="ios-note mt-2 text-xs text-gray-500">
|
||||
<p>This player uses native iOS controls for better compatibility with iOS devices.</p>
|
||||
</div>
|
||||
@ -183,4 +181,4 @@ const IOSVideoPlayer = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default IOSVideoPlayer;
|
||||
export default IOSVideoPlayer;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import '../styles/Modal.css';
|
||||
import React, { useEffect } from "react";
|
||||
import "../styles/Modal.css";
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
@ -9,36 +9,30 @@ interface ModalProps {
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
actions
|
||||
}) => {
|
||||
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, actions }) => {
|
||||
// Close modal when Escape key is pressed
|
||||
useEffect(() => {
|
||||
const handleEscapeKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen) {
|
||||
if (event.key === "Escape" && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscapeKey);
|
||||
|
||||
|
||||
document.addEventListener("keydown", handleEscapeKey);
|
||||
|
||||
// Disable body scrolling when modal is open
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscapeKey);
|
||||
document.body.style.overflow = '';
|
||||
document.removeEventListener("keydown", handleEscapeKey);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
|
||||
// Handle click outside the modal content to close it
|
||||
const handleClickOutside = (event: React.MouseEvent) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
@ -48,23 +42,19 @@ const Modal: React.FC<ModalProps> = ({
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleClickOutside}>
|
||||
<div className="modal-container" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-container" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">{title}</h2>
|
||||
<button
|
||||
className="modal-close-button"
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
<button className="modal-close-button" onClick={onClose} aria-label="Close modal">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
@ -72,19 +62,13 @@ const Modal: React.FC<ModalProps> = ({
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-content">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{actions && (
|
||||
<div className="modal-actions">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="modal-content">{children}</div>
|
||||
|
||||
{actions && <div className="modal-actions">{actions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
export default Modal;
|
||||
|
||||
@ -2878,8 +2878,8 @@ const TimelineControls = ({
|
||||
isPlayingSegments
|
||||
? "Disabled during preview"
|
||||
: isPlaying
|
||||
? "Pause playback"
|
||||
: "Play from current position"
|
||||
? "Pause playback"
|
||||
: "Play from current position"
|
||||
}
|
||||
style={{
|
||||
userSelect: "none",
|
||||
@ -3142,8 +3142,8 @@ const TimelineControls = ({
|
||||
isPlayingSegments
|
||||
? "Disabled during preview"
|
||||
: availableSegmentDuration < 0.5
|
||||
? "Not enough space for new segment"
|
||||
: "Create new segment"
|
||||
? "Not enough space for new segment"
|
||||
: "Create new segment"
|
||||
}
|
||||
disabled={availableSegmentDuration < 0.5 || isPlayingSegments}
|
||||
onClick={async (e) => {
|
||||
@ -3735,8 +3735,8 @@ const TimelineControls = ({
|
||||
isPlayingSegments
|
||||
? "Disabled during preview"
|
||||
: isPlaying
|
||||
? "Pause playback"
|
||||
: "Play from here until next segment"
|
||||
? "Pause playback"
|
||||
: "Play from here until next segment"
|
||||
}
|
||||
style={{
|
||||
userSelect: "none",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useRef, useEffect, useState } from "react";
|
||||
import { formatTime, formatDetailedTime } from "@/lib/timeUtils";
|
||||
import logger from '../lib/logger';
|
||||
import '../styles/VideoPlayer.css';
|
||||
import logger from "../lib/logger";
|
||||
import "../styles/VideoPlayer.css";
|
||||
|
||||
interface VideoPlayerProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
@ -32,37 +32,37 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
const isDraggingProgressRef = useRef(false);
|
||||
const [tooltipPosition, setTooltipPosition] = useState({ x: 0 });
|
||||
const [tooltipTime, setTooltipTime] = useState(0);
|
||||
|
||||
const sampleVideoUrl = typeof window !== 'undefined' &&
|
||||
(window as any).MEDIA_DATA?.videoUrl ||
|
||||
|
||||
const sampleVideoUrl =
|
||||
(typeof window !== "undefined" && (window as any).MEDIA_DATA?.videoUrl) ||
|
||||
"/videos/sample-video-10m.mp4";
|
||||
|
||||
|
||||
// Detect iOS device
|
||||
useEffect(() => {
|
||||
const checkIOS = () => {
|
||||
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
|
||||
return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
|
||||
};
|
||||
|
||||
|
||||
setIsIOS(checkIOS());
|
||||
|
||||
|
||||
// Check if video was previously initialized
|
||||
if (typeof window !== 'undefined') {
|
||||
const wasInitialized = localStorage.getItem('video_initialized') === 'true';
|
||||
if (typeof window !== "undefined") {
|
||||
const wasInitialized = localStorage.getItem("video_initialized") === "true";
|
||||
setHasInitialized(wasInitialized);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
// Update initialized state when video plays
|
||||
useEffect(() => {
|
||||
if (isPlaying && !hasInitialized) {
|
||||
setHasInitialized(true);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('video_initialized', 'true');
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("video_initialized", "true");
|
||||
}
|
||||
}
|
||||
}, [isPlaying, hasInitialized]);
|
||||
|
||||
|
||||
// Add iOS-specific attributes to prevent fullscreen playback
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
@ -70,15 +70,15 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
|
||||
// These attributes need to be set directly on the DOM element
|
||||
// for iOS Safari to respect inline playback
|
||||
video.setAttribute('playsinline', 'true');
|
||||
video.setAttribute('webkit-playsinline', 'true');
|
||||
video.setAttribute('x-webkit-airplay', 'allow');
|
||||
video.setAttribute("playsinline", "true");
|
||||
video.setAttribute("webkit-playsinline", "true");
|
||||
video.setAttribute("x-webkit-airplay", "allow");
|
||||
|
||||
// Store the last known good position for iOS
|
||||
const handleTimeUpdate = () => {
|
||||
if (!isDraggingProgressRef.current) {
|
||||
setLastPosition(video.currentTime);
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
window.lastSeekedPosition = video.currentTime;
|
||||
}
|
||||
}
|
||||
@ -86,33 +86,33 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
|
||||
// Handle iOS-specific play/pause state
|
||||
const handlePlay = () => {
|
||||
logger.debug('Video play event fired');
|
||||
logger.debug("Video play event fired");
|
||||
if (isIOS) {
|
||||
setHasInitialized(true);
|
||||
localStorage.setItem('video_initialized', 'true');
|
||||
localStorage.setItem("video_initialized", "true");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
logger.debug('Video pause event fired');
|
||||
logger.debug("Video pause event fired");
|
||||
};
|
||||
|
||||
video.addEventListener('timeupdate', handleTimeUpdate);
|
||||
video.addEventListener('play', handlePlay);
|
||||
video.addEventListener('pause', handlePause);
|
||||
video.addEventListener("timeupdate", handleTimeUpdate);
|
||||
video.addEventListener("play", handlePlay);
|
||||
video.addEventListener("pause", handlePause);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
video.removeEventListener("timeupdate", handleTimeUpdate);
|
||||
video.removeEventListener("play", handlePlay);
|
||||
video.removeEventListener("pause", handlePause);
|
||||
};
|
||||
}, [videoRef, isIOS, isDraggingProgressRef]);
|
||||
|
||||
|
||||
// Save current time to lastPosition when it changes (from external seeking)
|
||||
useEffect(() => {
|
||||
setLastPosition(currentTime);
|
||||
}, [currentTime]);
|
||||
|
||||
|
||||
// Jump 10 seconds forward
|
||||
const handleForward = () => {
|
||||
const newTime = Math.min(currentTime + 10, duration);
|
||||
@ -126,58 +126,58 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
onSeek(newTime);
|
||||
setLastPosition(newTime);
|
||||
};
|
||||
|
||||
|
||||
// Calculate progress percentage
|
||||
const progressPercentage = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
// Handle start of progress bar dragging
|
||||
const handleProgressDragStart = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
setIsDraggingProgress(true);
|
||||
isDraggingProgressRef.current = true;
|
||||
|
||||
|
||||
// Get initial position
|
||||
handleProgressDrag(e);
|
||||
|
||||
|
||||
// Set up document-level event listeners for mouse movement and release
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (isDraggingProgressRef.current) {
|
||||
handleProgressDrag(moveEvent);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDraggingProgress(false);
|
||||
isDraggingProgressRef.current = false;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
|
||||
// Handle progress dragging for both mouse and touch events
|
||||
const handleProgressDrag = (e: MouseEvent | React.MouseEvent) => {
|
||||
if (!progressRef.current) return;
|
||||
|
||||
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const clickPosition = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const seekTime = duration * clickPosition;
|
||||
|
||||
|
||||
// Update tooltip position and time
|
||||
setTooltipPosition({ x: e.clientX });
|
||||
setTooltipTime(seekTime);
|
||||
|
||||
|
||||
// Store position locally for iOS Safari - critical for timeline seeking
|
||||
setLastPosition(seekTime);
|
||||
|
||||
|
||||
// Also store globally for integration with other components
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any).lastSeekedPosition = seekTime;
|
||||
}
|
||||
|
||||
|
||||
onSeek(seekTime);
|
||||
};
|
||||
|
||||
@ -185,59 +185,59 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
const handleProgressTouchStart = (e: React.TouchEvent) => {
|
||||
if (!progressRef.current || !e.touches[0]) return;
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
setIsDraggingProgress(true);
|
||||
isDraggingProgressRef.current = true;
|
||||
|
||||
|
||||
// Get initial position using touch
|
||||
handleProgressTouchMove(e);
|
||||
|
||||
|
||||
// Set up document-level event listeners for touch movement and release
|
||||
const handleTouchMove = (moveEvent: TouchEvent) => {
|
||||
if (isDraggingProgressRef.current) {
|
||||
handleProgressTouchMove(moveEvent);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
setIsDraggingProgress(false);
|
||||
isDraggingProgressRef.current = false;
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
document.removeEventListener('touchcancel', handleTouchEnd);
|
||||
document.removeEventListener("touchmove", handleTouchMove);
|
||||
document.removeEventListener("touchend", handleTouchEnd);
|
||||
document.removeEventListener("touchcancel", handleTouchEnd);
|
||||
};
|
||||
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
document.addEventListener('touchcancel', handleTouchEnd);
|
||||
|
||||
document.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||
document.addEventListener("touchend", handleTouchEnd);
|
||||
document.addEventListener("touchcancel", handleTouchEnd);
|
||||
};
|
||||
|
||||
|
||||
// Handle touch dragging on progress bar
|
||||
const handleProgressTouchMove = (e: TouchEvent | React.TouchEvent) => {
|
||||
if (!progressRef.current) return;
|
||||
|
||||
|
||||
// Get the touch coordinates
|
||||
const touch = 'touches' in e ? e.touches[0] : null;
|
||||
const touch = "touches" in e ? e.touches[0] : null;
|
||||
if (!touch) return;
|
||||
|
||||
|
||||
e.preventDefault(); // Prevent scrolling while dragging
|
||||
|
||||
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const touchPosition = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
|
||||
const seekTime = duration * touchPosition;
|
||||
|
||||
|
||||
// Update tooltip position and time
|
||||
setTooltipPosition({ x: touch.clientX });
|
||||
setTooltipTime(seekTime);
|
||||
|
||||
|
||||
// Store position for iOS Safari
|
||||
setLastPosition(seekTime);
|
||||
|
||||
|
||||
// Also store globally for integration with other components
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any).lastSeekedPosition = seekTime;
|
||||
}
|
||||
|
||||
|
||||
onSeek(seekTime);
|
||||
};
|
||||
|
||||
@ -245,20 +245,20 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// If we're already dragging, don't handle the click
|
||||
if (isDraggingProgress) return;
|
||||
|
||||
|
||||
if (progressRef.current) {
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const clickPosition = (e.clientX - rect.left) / rect.width;
|
||||
const seekTime = duration * clickPosition;
|
||||
|
||||
|
||||
// Store position locally for iOS Safari - critical for timeline seeking
|
||||
setLastPosition(seekTime);
|
||||
|
||||
|
||||
// Also store globally for integration with other components
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any).lastSeekedPosition = seekTime;
|
||||
}
|
||||
|
||||
|
||||
onSeek(seekTime);
|
||||
}
|
||||
};
|
||||
@ -278,38 +278,43 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
const handleVideoClick = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
|
||||
// If the video is paused, we want to play it
|
||||
if (video.paused) {
|
||||
// For iOS Safari: Before playing, explicitly seek to the remembered position
|
||||
if (isIOS && lastPosition !== null && lastPosition > 0) {
|
||||
logger.debug("iOS: Explicitly setting position before play:", lastPosition);
|
||||
|
||||
|
||||
// First, seek to the position
|
||||
video.currentTime = lastPosition;
|
||||
|
||||
|
||||
// Use a small timeout to ensure seeking is complete before play
|
||||
setTimeout(() => {
|
||||
if (videoRef.current) {
|
||||
// Try to play with proper promise handling
|
||||
videoRef.current.play()
|
||||
videoRef.current
|
||||
.play()
|
||||
.then(() => {
|
||||
logger.debug("iOS: Play started successfully at position:", videoRef.current?.currentTime);
|
||||
logger.debug(
|
||||
"iOS: Play started successfully at position:",
|
||||
videoRef.current?.currentTime
|
||||
);
|
||||
onPlayPause(); // Update parent state after successful play
|
||||
})
|
||||
.catch(err => {
|
||||
.catch((err) => {
|
||||
console.error("iOS: Error playing video:", err);
|
||||
});
|
||||
}
|
||||
}, 50);
|
||||
} else {
|
||||
// Normal play (non-iOS or no remembered position)
|
||||
video.play()
|
||||
video
|
||||
.play()
|
||||
.then(() => {
|
||||
logger.debug("Normal: Play started successfully");
|
||||
onPlayPause(); // Update parent state after successful play
|
||||
})
|
||||
.catch(err => {
|
||||
.catch((err) => {
|
||||
console.error("Error playing video:", err);
|
||||
});
|
||||
}
|
||||
@ -336,19 +341,17 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
<source src={sampleVideoUrl} type="video/mp4" />
|
||||
<p>Your browser doesn't support HTML5 video.</p>
|
||||
</video>
|
||||
|
||||
|
||||
{/* iOS First-play indicator - only shown on first visit for iOS devices when not initialized */}
|
||||
{isIOS && !hasInitialized && !isPlaying && (
|
||||
<div className="ios-first-play-indicator">
|
||||
<div className="ios-play-message">
|
||||
Tap Play to initialize video controls
|
||||
</div>
|
||||
<div className="ios-play-message">Tap Play to initialize video controls</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Play/Pause Indicator (shows based on current state) */}
|
||||
<div className={`play-pause-indicator ${isPlaying ? 'pause-icon' : 'play-icon'}`}></div>
|
||||
|
||||
<div className={`play-pause-indicator ${isPlaying ? "pause-icon" : "play-icon"}`}></div>
|
||||
|
||||
{/* Video Controls Overlay */}
|
||||
<div className="video-controls">
|
||||
{/* Time and Duration */}
|
||||
@ -356,47 +359,52 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
<span className="video-current-time">{formatTime(currentTime)}</span>
|
||||
<span className="video-duration">/ {formatTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Progress Bar with enhanced dragging */}
|
||||
<div
|
||||
<div
|
||||
ref={progressRef}
|
||||
className={`video-progress ${isDraggingProgress ? 'dragging' : ''}`}
|
||||
className={`video-progress ${isDraggingProgress ? "dragging" : ""}`}
|
||||
onClick={handleProgressClick}
|
||||
onMouseDown={handleProgressDragStart}
|
||||
onTouchStart={handleProgressTouchStart}
|
||||
>
|
||||
<div
|
||||
className="video-progress-fill"
|
||||
style={{ width: `${progressPercentage}%` }}
|
||||
></div>
|
||||
<div
|
||||
className="video-scrubber"
|
||||
style={{ left: `${progressPercentage}%` }}
|
||||
></div>
|
||||
|
||||
<div className="video-progress-fill" style={{ width: `${progressPercentage}%` }}></div>
|
||||
<div className="video-scrubber" style={{ left: `${progressPercentage}%` }}></div>
|
||||
|
||||
{/* Floating time tooltip when dragging */}
|
||||
{isDraggingProgress && (
|
||||
<div className="video-time-tooltip" style={{
|
||||
left: `${tooltipPosition.x}px`,
|
||||
transform: 'translateX(-50%)'
|
||||
}}>
|
||||
<div
|
||||
className="video-time-tooltip"
|
||||
style={{
|
||||
left: `${tooltipPosition.x}px`,
|
||||
transform: "translateX(-50%)"
|
||||
}}
|
||||
>
|
||||
{formatDetailedTime(tooltipTime)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Controls - Mute and Fullscreen buttons */}
|
||||
<div className="video-controls-buttons">
|
||||
{/* Mute/Unmute Button */}
|
||||
{onToggleMute && (
|
||||
<button
|
||||
className="mute-button"
|
||||
<button
|
||||
className="mute-button"
|
||||
aria-label={isMuted ? "Unmute" : "Mute"}
|
||||
onClick={onToggleMute}
|
||||
data-tooltip={isMuted ? "Unmute" : "Mute"}
|
||||
>
|
||||
{isMuted ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="1" y1="1" x2="23" y2="23"></line>
|
||||
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path>
|
||||
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path>
|
||||
@ -404,23 +412,35 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
<line x1="8" y1="23" x2="16" y2="23"></line>
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
{/* Fullscreen Button */}
|
||||
<button
|
||||
className="fullscreen-button"
|
||||
<button
|
||||
className="fullscreen-button"
|
||||
aria-label="Fullscreen"
|
||||
onClick={handleFullscreen}
|
||||
data-tooltip="Toggle fullscreen"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 01-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 011.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 011.414-1.414L15 13.586V12a1 1 0 011-1z" clipRule="evenodd" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 01-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 011.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 011.414-1.414L15 13.586V12a1 1 0 011-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -125,13 +125,13 @@
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
margin-bottom: 0.75rem;
|
||||
background-color: #EEE; /* Very light gray background */
|
||||
background-color: #eee; /* Very light gray background */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-container {
|
||||
position: relative;
|
||||
background-color: #EEE; /* Very light gray background */
|
||||
background-color: #eee; /* Very light gray background */
|
||||
height: 6rem;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
@ -208,17 +208,27 @@
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: box-shadow 0.2s, transform 0.1s;
|
||||
transition:
|
||||
box-shadow 0.2s,
|
||||
transform 0.1s;
|
||||
/* Original z-index for stacking order based on segment ID */
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
/* No background colors for segments, just borders with 2-color scheme */
|
||||
.clip-segment:nth-child(odd), .segment-color-1, .segment-color-3, .segment-color-5, .segment-color-7 {
|
||||
.clip-segment:nth-child(odd),
|
||||
.segment-color-1,
|
||||
.segment-color-3,
|
||||
.segment-color-5,
|
||||
.segment-color-7 {
|
||||
background-color: transparent;
|
||||
border: 2px solid rgba(0, 123, 255, 0.9); /* Blue border */
|
||||
}
|
||||
.clip-segment:nth-child(even), .segment-color-2, .segment-color-4, .segment-color-6, .segment-color-8 {
|
||||
.clip-segment:nth-child(even),
|
||||
.segment-color-2,
|
||||
.segment-color-4,
|
||||
.segment-color-6,
|
||||
.segment-color-8 {
|
||||
background-color: transparent;
|
||||
border: 2px solid rgba(108, 117, 125, 0.9); /* Gray border */
|
||||
}
|
||||
@ -315,7 +325,7 @@
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
height: 6px;
|
||||
background: #E0E0E0;
|
||||
background: #e0e0e0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@ -350,12 +360,14 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
@ -366,7 +378,9 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
margin-bottom: 0px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@ -464,7 +478,7 @@ button[disabled][data-tooltip]::after {
|
||||
}
|
||||
|
||||
.segment-tooltip::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
@ -539,7 +553,7 @@ button[disabled][data-tooltip]::after {
|
||||
}
|
||||
|
||||
.empty-space-tooltip::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
left: 50%;
|
||||
@ -617,7 +631,9 @@ button[disabled][data-tooltip]::after {
|
||||
}
|
||||
|
||||
/* Save buttons styling */
|
||||
.save-button, .save-copy-button, .save-segments-button {
|
||||
.save-button,
|
||||
.save-copy-button,
|
||||
.save-segments-button {
|
||||
background-color: rgba(0, 123, 255, 0.8);
|
||||
color: white;
|
||||
border: none;
|
||||
@ -628,7 +644,8 @@ button[disabled][data-tooltip]::after {
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.save-button:hover, .save-copy-button:hover {
|
||||
.save-button:hover,
|
||||
.save-copy-button:hover {
|
||||
background-color: rgba(0, 123, 255, 1);
|
||||
}
|
||||
|
||||
@ -735,7 +752,8 @@ button[disabled][data-tooltip]::after {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.current-time, .duration-time {
|
||||
.current-time,
|
||||
.duration-time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@ -770,7 +788,8 @@ button[disabled][data-tooltip]::after {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.save-button, .save-copy-button {
|
||||
.save-button,
|
||||
.save-copy-button {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@ -7,25 +7,25 @@ const logger = {
|
||||
* Logs debug messages only in development environment
|
||||
*/
|
||||
debug: (...args: any[]) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.debug(...args);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Always logs error messages
|
||||
*/
|
||||
error: (...args: any[]) => console.error(...args),
|
||||
|
||||
|
||||
/**
|
||||
* Always logs warning messages
|
||||
*/
|
||||
warn: (...args: any[]) => console.warn(...args),
|
||||
|
||||
|
||||
/**
|
||||
* Always logs info messages
|
||||
*/
|
||||
info: (...args: any[]) => console.info(...args)
|
||||
};
|
||||
|
||||
export default logger;
|
||||
export default logger;
|
||||
|
||||
@ -10,13 +10,13 @@ async function throwIfResNotOk(res: Response) {
|
||||
export async function apiRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
data?: unknown | undefined,
|
||||
data?: unknown | undefined
|
||||
): Promise<Response> {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: data ? { "Content-Type": "application/json" } : {},
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
credentials: "include",
|
||||
credentials: "include"
|
||||
});
|
||||
|
||||
await throwIfResNotOk(res);
|
||||
@ -24,13 +24,11 @@ export async function apiRequest(
|
||||
}
|
||||
|
||||
type UnauthorizedBehavior = "returnNull" | "throw";
|
||||
export const getQueryFn: <T>(options: {
|
||||
on401: UnauthorizedBehavior;
|
||||
}) => QueryFunction<T> =
|
||||
export const getQueryFn: <T>(options: { on401: UnauthorizedBehavior }) => QueryFunction<T> =
|
||||
({ on401: unauthorizedBehavior }) =>
|
||||
async ({ queryKey }) => {
|
||||
const res = await fetch(queryKey[0] as string, {
|
||||
credentials: "include",
|
||||
credentials: "include"
|
||||
});
|
||||
|
||||
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
|
||||
@ -48,10 +46,10 @@ export const queryClient = new QueryClient({
|
||||
refetchInterval: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
retry: false
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
retry: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -3,17 +3,17 @@
|
||||
*/
|
||||
export const formatDetailedTime = (seconds: number): string => {
|
||||
if (isNaN(seconds)) return "00:00:00.000";
|
||||
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
const milliseconds = Math.round((seconds % 1) * 1000);
|
||||
|
||||
|
||||
const formattedHours = String(hours).padStart(2, "0");
|
||||
const formattedMinutes = String(minutes).padStart(2, "0");
|
||||
const formattedSeconds = String(remainingSeconds).padStart(2, "0");
|
||||
const formattedMilliseconds = String(milliseconds).padStart(3, "0");
|
||||
|
||||
|
||||
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}`;
|
||||
};
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
@ -2,20 +2,17 @@
|
||||
* Generate a solid color background for a segment
|
||||
* Returns a CSS color based on the segment position
|
||||
*/
|
||||
export const generateSolidColor = (
|
||||
time: number,
|
||||
duration: number
|
||||
): string => {
|
||||
export const generateSolidColor = (time: number, duration: number): string => {
|
||||
// Use the time position to create different colors
|
||||
// This gives each segment a different color without needing an image
|
||||
const position = Math.min(Math.max(time / (duration || 1), 0), 1);
|
||||
|
||||
|
||||
// Calculate color based on position
|
||||
// Use an extremely light blue-based color palette
|
||||
const hue = 210; // Blue base
|
||||
const saturation = 40 + Math.floor(position * 20); // 40-60% (less saturated)
|
||||
const lightness = 85 + Math.floor(position * 8); // 85-93% (extremely light)
|
||||
|
||||
|
||||
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||
};
|
||||
|
||||
@ -24,27 +21,27 @@ export const generateSolidColor = (
|
||||
* Now returns a data URL for a solid color square instead of a video thumbnail
|
||||
*/
|
||||
export const generateThumbnail = async (
|
||||
videoElement: HTMLVideoElement,
|
||||
videoElement: HTMLVideoElement,
|
||||
time: number
|
||||
): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
// Create a small canvas for the solid color
|
||||
const canvas = document.createElement('canvas');
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 10; // Much smaller - we only need a color
|
||||
canvas.height = 10;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
// Get the solid color based on time
|
||||
const color = generateSolidColor(time, videoElement.duration);
|
||||
|
||||
|
||||
// Fill with solid color
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
|
||||
// Convert to data URL (much smaller now)
|
||||
const dataUrl = canvas.toDataURL('image/png', 0.5);
|
||||
const dataUrl = canvas.toDataURL("image/png", 0.5);
|
||||
resolve(dataUrl);
|
||||
});
|
||||
};
|
||||
|
||||
@ -2,7 +2,7 @@ import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
window.MEDIA_DATA = {
|
||||
videoUrl: "",
|
||||
mediaId: ""
|
||||
@ -30,8 +30,8 @@ const mountComponents = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', mountComponents);
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", mountComponents);
|
||||
} else {
|
||||
mountComponents();
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,36 +4,36 @@ interface TrimVideoRequest {
|
||||
segments: {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
name?: string;
|
||||
name?: string;
|
||||
}[];
|
||||
saveAsCopy?: boolean;
|
||||
saveIndividualSegments?: boolean;
|
||||
saveIndividualSegments?: boolean;
|
||||
}
|
||||
|
||||
interface TrimVideoResponse {
|
||||
msg: string;
|
||||
url_redirect: string;
|
||||
status?: number; // HTTP status code for success/error
|
||||
error?: string; // Error message if status is not 200
|
||||
status?: number; // HTTP status code for success/error
|
||||
error?: string; // Error message if status is not 200
|
||||
}
|
||||
|
||||
// Helper function to simulate delay
|
||||
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// For now, we'll use a mock API that returns a promise
|
||||
// This can be replaced with actual API calls later
|
||||
export const trimVideo = async (
|
||||
mediaId: string,
|
||||
mediaId: string,
|
||||
data: TrimVideoRequest
|
||||
): Promise<TrimVideoResponse> => {
|
||||
try {
|
||||
// Attempt the real API call
|
||||
const response = await fetch(`/api/v1/media/${mediaId}/trim_video`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
// For error responses, return with error status and message
|
||||
if (response.status === 400) {
|
||||
@ -86,7 +86,7 @@ export const trimVideo = async (
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Successful response
|
||||
const jsonResponse = await response.json();
|
||||
return {
|
||||
@ -104,7 +104,7 @@ export const trimVideo = async (
|
||||
url_redirect: `./view?m=${mediaId}`
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/* Mock implementation that simulates network latency
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
@ -115,4 +115,4 @@ export const trimVideo = async (
|
||||
}, 1500); // Simulate 1.5 second server delay
|
||||
});
|
||||
*/
|
||||
};
|
||||
};
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
[data-tooltip]:before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
@ -21,13 +21,15 @@
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
[data-tooltip]:after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
@ -37,17 +39,19 @@
|
||||
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
[data-tooltip]:hover:before,
|
||||
[data-tooltip]:hover:after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Hide button tooltips on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
[data-tooltip]:before,
|
||||
@ -143,7 +147,9 @@
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
color 0.2s;
|
||||
min-width: auto;
|
||||
|
||||
&:hover {
|
||||
@ -163,12 +169,28 @@
|
||||
color: rgba(51, 51, 51, 0.7);
|
||||
}
|
||||
|
||||
.segment-color-1 { background-color: rgba(59, 130, 246, 0.15); }
|
||||
.segment-color-2 { background-color: rgba(16, 185, 129, 0.15); }
|
||||
.segment-color-3 { background-color: rgba(245, 158, 11, 0.15); }
|
||||
.segment-color-4 { background-color: rgba(239, 68, 68, 0.15); }
|
||||
.segment-color-5 { background-color: rgba(139, 92, 246, 0.15); }
|
||||
.segment-color-6 { background-color: rgba(236, 72, 153, 0.15); }
|
||||
.segment-color-7 { background-color: rgba(6, 182, 212, 0.15); }
|
||||
.segment-color-8 { background-color: rgba(250, 204, 21, 0.15); }
|
||||
}
|
||||
.segment-color-1 {
|
||||
background-color: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
.segment-color-2 {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
.segment-color-3 {
|
||||
background-color: rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
.segment-color-4 {
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
.segment-color-5 {
|
||||
background-color: rgba(139, 92, 246, 0.15);
|
||||
}
|
||||
.segment-color-6 {
|
||||
background-color: rgba(236, 72, 153, 0.15);
|
||||
}
|
||||
.segment-color-7 {
|
||||
background-color: rgba(6, 182, 212, 0.15);
|
||||
}
|
||||
.segment-color-8 {
|
||||
background-color: rgba(250, 204, 21, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,13 +21,15 @@
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]:after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
@ -37,7 +39,9 @@
|
||||
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@ -132,7 +132,7 @@
|
||||
.ios-notification {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
|
||||
.ios-notification-close {
|
||||
padding: 10px;
|
||||
}
|
||||
@ -143,11 +143,11 @@
|
||||
.ios-notification-content {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
|
||||
.ios-notification-message h3 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
|
||||
.ios-notification-message p,
|
||||
.ios-notification-message ol {
|
||||
font-size: 13px;
|
||||
@ -164,4 +164,4 @@ html.ios-device {
|
||||
html.ios-device .ios-control-btn {
|
||||
/* Make buttons easier to tap in desktop mode */
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,4 +93,4 @@
|
||||
/* Extra spacing for mobile */
|
||||
padding: 14px 25px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,13 +36,13 @@
|
||||
.ios-video-player-container video {
|
||||
max-height: 50vh; /* Use viewport height on iOS */
|
||||
}
|
||||
|
||||
|
||||
/* Improve controls visibility on iOS */
|
||||
video::-webkit-media-controls {
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
|
||||
/* Ensure controls don't disappear too quickly */
|
||||
video::-webkit-media-controls-panel {
|
||||
transition-duration: 3s !important;
|
||||
@ -76,19 +76,19 @@
|
||||
/* Prevent text selection on buttons */
|
||||
.no-select {
|
||||
-webkit-touch-callout: none; /* iOS Safari */
|
||||
-webkit-user-select: none; /* Safari */
|
||||
-khtml-user-select: none; /* Konqueror HTML */
|
||||
-moz-user-select: none; /* Firefox */
|
||||
-ms-user-select: none; /* Internet Explorer/Edge */
|
||||
user-select: none; /* Non-prefixed version, supported by Chrome and Opera */
|
||||
-webkit-user-select: none; /* Safari */
|
||||
-khtml-user-select: none; /* Konqueror HTML */
|
||||
-moz-user-select: none; /* Firefox */
|
||||
-ms-user-select: none; /* Internet Explorer/Edge */
|
||||
user-select: none; /* Non-prefixed version, supported by Chrome and Opera */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Specifically prevent default behavior on fine controls */
|
||||
.ios-fine-controls button,
|
||||
.ios-fine-controls button,
|
||||
.ios-external-controls .no-select {
|
||||
touch-action: manipulation;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,302 +1,306 @@
|
||||
#video-editor-trim-root {
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
animation: modal-fade-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modal-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.modal-close-button:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #eee;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.modal-button-primary {
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-button-primary:hover {
|
||||
background-color: #0055aa;
|
||||
}
|
||||
|
||||
.modal-button-secondary {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-button-secondary:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.modal-button-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-button-danger:hover {
|
||||
background-color: #bd2130;
|
||||
}
|
||||
|
||||
/* Modal content styles */
|
||||
.modal-message {
|
||||
margin-bottom: 16px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 4px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 50%;
|
||||
border-top: 4px solid #0066cc;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.modal-success-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
color: #28a745;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.modal-success-icon svg {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
color: #4CAF50;
|
||||
animation: success-pop 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes success-pop {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-error-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
color: #dc3545;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.modal-error-icon svg {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
color: #F44336;
|
||||
animation: error-pop 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes error-pop {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.modal-choice-button {
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: #0066cc;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-choice-button:hover {
|
||||
background-color: #0055aa;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.modal-choice-button svg {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.success-link {
|
||||
background-color: #4CAF50;
|
||||
}
|
||||
|
||||
.success-link:hover {
|
||||
background-color: #3d8b40;
|
||||
}
|
||||
|
||||
.centered-choice {
|
||||
margin: 0 auto;
|
||||
width: auto;
|
||||
min-width: 220px;
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.centered-choice:hover {
|
||||
background-color: #0055aa;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.modal-container {
|
||||
width: 95%;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
animation: modal-fade-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
|
||||
@keyframes modal-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.modal-close-button:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #eee;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
.modal-button {
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.modal-button-primary {
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-button-primary:hover {
|
||||
background-color: #0055aa;
|
||||
}
|
||||
|
||||
.modal-button-secondary {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-button-secondary:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.modal-button-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-button-danger:hover {
|
||||
background-color: #bd2130;
|
||||
}
|
||||
|
||||
/* Modal content styles */
|
||||
.modal-message {
|
||||
margin-bottom: 16px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 4px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 50%;
|
||||
border-top: 4px solid #0066cc;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-success-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
color: #28a745;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.modal-success-icon svg {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
color: #4caf50;
|
||||
animation: success-pop 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes success-pop {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-error-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
color: #dc3545;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.modal-error-icon svg {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
color: #f44336;
|
||||
animation: error-pop 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes error-pop {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.modal-choice-button {
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: #0066cc;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-choice-button:hover {
|
||||
background-color: #0055aa;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.modal-choice-button svg {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.success-link {
|
||||
background-color: #4caf50;
|
||||
}
|
||||
|
||||
.success-link:hover {
|
||||
background-color: #3d8b40;
|
||||
}
|
||||
|
||||
.centered-choice {
|
||||
margin: 0 auto;
|
||||
width: auto;
|
||||
min-width: 220px;
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.centered-choice:hover {
|
||||
background-color: #0055aa;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.modal-container {
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #f44336;
|
||||
font-weight: 500;
|
||||
background-color: rgba(244, 67, 54, 0.1);
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid #f44336;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.redirect-message {
|
||||
margin-top: 20px;
|
||||
color: #555;
|
||||
font-size: 0.95rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
font-weight: bold;
|
||||
color: #0066cc;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #F44336;
|
||||
font-weight: 500;
|
||||
background-color: rgba(244, 67, 54, 0.1);
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid #F44336;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.redirect-message {
|
||||
margin-top: 20px;
|
||||
color: #555;
|
||||
font-size: 0.95rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
font-weight: bold;
|
||||
color: #0066cc;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
@ -257,7 +257,7 @@
|
||||
}
|
||||
|
||||
.clip-segment-handle:after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
@ -321,7 +321,7 @@
|
||||
|
||||
.segment-tooltip:after,
|
||||
.empty-space-tooltip:after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
left: 50%;
|
||||
@ -335,7 +335,7 @@
|
||||
|
||||
.segment-tooltip:before,
|
||||
.empty-space-tooltip:before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
@ -612,13 +612,15 @@
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]:after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
@ -628,7 +630,9 @@
|
||||
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@ -111,7 +111,9 @@
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
z-index: 2500; /* High z-index */
|
||||
pointer-events: none;
|
||||
}
|
||||
@ -130,7 +132,9 @@
|
||||
margin-left: 0; /* Reset margin */
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
z-index: 2500; /* High z-index */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
[data-tooltip]:before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
@ -21,13 +21,15 @@
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
[data-tooltip]:after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
@ -37,17 +39,19 @@
|
||||
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
[data-tooltip]:hover:before,
|
||||
[data-tooltip]:hover:after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Hide button tooltips on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
[data-tooltip]:before,
|
||||
@ -71,7 +75,7 @@
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
||||
.video-player-container video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@ -83,7 +87,7 @@
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
||||
/* iOS-specific styles */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.video-player-container video {
|
||||
@ -92,7 +96,7 @@
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.play-pause-indicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@ -106,19 +110,19 @@
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
.video-player-container:hover .play-pause-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
.play-pause-indicator::before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
|
||||
.play-pause-indicator.play-icon::before {
|
||||
width: 0;
|
||||
height: 0;
|
||||
@ -127,14 +131,14 @@
|
||||
border-left: 25px solid white;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
|
||||
.play-pause-indicator.pause-icon::before {
|
||||
width: 20px;
|
||||
height: 25px;
|
||||
border-left: 6px solid white;
|
||||
border-right: 6px solid white;
|
||||
}
|
||||
|
||||
|
||||
/* iOS First-play indicator */
|
||||
.ios-first-play-indicator {
|
||||
position: absolute;
|
||||
@ -148,7 +152,7 @@
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
|
||||
.ios-play-message {
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
@ -158,13 +162,22 @@
|
||||
border-radius: 0.5rem;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 0.7; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.05); }
|
||||
100% { opacity: 0.7; transform: scale(1); }
|
||||
0% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.video-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@ -175,21 +188,21 @@
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
|
||||
.video-player-container:hover .video-controls {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
.video-current-time {
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
|
||||
.video-duration {
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
|
||||
.video-time-display {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@ -197,7 +210,7 @@
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
|
||||
.video-progress {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
@ -208,11 +221,11 @@
|
||||
touch-action: none; /* Prevent browser handling of drag gestures */
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
|
||||
.video-progress.dragging {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
|
||||
.video-progress-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@ -222,7 +235,7 @@
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
.video-scrubber {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@ -232,9 +245,12 @@
|
||||
background-color: #ff0000;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
transition: transform 0.1s ease, width 0.1s ease, height 0.1s ease;
|
||||
transition:
|
||||
transform 0.1s ease,
|
||||
width 0.1s ease,
|
||||
height 0.1s ease;
|
||||
}
|
||||
|
||||
|
||||
/* Make the scrubber larger when dragging for better control */
|
||||
.video-progress.dragging .video-scrubber {
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
@ -243,22 +259,22 @@
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 0 8px rgba(255, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
|
||||
/* Enhance for touch devices */
|
||||
@media (pointer: coarse) {
|
||||
.video-scrubber {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
|
||||
.video-progress.dragging .video-scrubber {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
|
||||
/* Create a larger invisible touch target */
|
||||
.video-scrubber:before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: -10px;
|
||||
@ -266,14 +282,14 @@
|
||||
bottom: -10px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.video-controls-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
.mute-button,
|
||||
.fullscreen-button {
|
||||
min-width: auto;
|
||||
@ -283,17 +299,17 @@
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
transition: transform 0.2s;
|
||||
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
|
||||
svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Time tooltip that appears when dragging */
|
||||
.video-time-tooltip {
|
||||
position: absolute;
|
||||
@ -309,10 +325,10 @@
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
|
||||
/* Add a small arrow to the tooltip */
|
||||
.video-time-tooltip:after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
left: 50%;
|
||||
@ -323,4 +339,4 @@
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 4px solid rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user