chore: video-editor update with new prettier settings

This commit is contained in:
Yiannis Christodoulou 2025-07-27 21:25:44 +03:00
parent eaf87e20d8
commit 074638e237
25 changed files with 12707 additions and 10118 deletions

View File

@ -1,85 +1,83 @@
import { formatTime, formatLongTime } from "@/lib/timeUtils"; import { formatTime, formatLongTime } from '@/lib/timeUtils';
import "../styles/ClipSegments.css"; import '../styles/ClipSegments.css';
export interface Segment { export interface Segment {
id: number; id: number;
name: string; name: string;
startTime: number; startTime: number;
endTime: number; endTime: number;
thumbnail: string; thumbnail: string;
} }
interface ClipSegmentsProps { interface ClipSegmentsProps {
segments: Segment[]; segments: Segment[];
} }
const ClipSegments = ({ segments }: ClipSegmentsProps) => { const ClipSegments = ({ segments }: ClipSegmentsProps) => {
// Sort segments by startTime // Sort segments by startTime
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime); const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
// Handle delete segment click // Handle delete segment click
const handleDeleteSegment = (segmentId: number) => { const handleDeleteSegment = (segmentId: number) => {
// Create and dispatch the delete event // Create and dispatch the delete event
const deleteEvent = new CustomEvent("delete-segment", { const deleteEvent = new CustomEvent('delete-segment', {
detail: { segmentId } detail: { segmentId },
}); });
document.dispatchEvent(deleteEvent); document.dispatchEvent(deleteEvent);
}; };
// Generate the same color background for a segment as shown in the timeline // Generate the same color background for a segment as shown in the timeline
const getSegmentColorClass = (index: number) => { 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 // This matches the CSS nth-child selectors in the timeline
return `segment-default-color segment-color-${(index % 8) + 1}`; return `segment-default-color segment-color-${(index % 8) + 1}`;
}; };
return ( return (
<div className="clip-segments-container"> <div className="clip-segments-container">
<h3 className="clip-segments-title">Clip Segments</h3> <h3 className="clip-segments-title">Clip Segments</h3>
{sortedSegments.map((segment, index) => ( {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-content">
<div <div
className="segment-thumbnail" className="segment-thumbnail"
style={{ backgroundImage: `url(${segment.thumbnail})` }} style={{ backgroundImage: `url(${segment.thumbnail})` }}
></div> ></div>
<div className="segment-info"> <div className="segment-info">
<div className="segment-title">Segment {index + 1}</div> <div className="segment-title">Segment {index + 1}</div>
<div className="segment-time"> <div className="segment-time">
{formatTime(segment.startTime)} - {formatTime(segment.endTime)} {formatTime(segment.startTime)} - {formatTime(segment.endTime)}
</div> </div>
<div className="segment-duration"> <div className="segment-duration">
Duration: {formatLongTime(segment.endTime - segment.startTime)} Duration: {formatLongTime(segment.endTime - segment.startTime)}
</div> </div>
</div> </div>
</div> </div>
<div className="segment-actions"> <div className="segment-actions">
<button <button
className="delete-button" className="delete-button"
aria-label="Delete Segment" aria-label="Delete Segment"
data-tooltip="Delete this segment" data-tooltip="Delete this segment"
onClick={() => handleDeleteSegment(segment.id)} onClick={() => handleDeleteSegment(segment.id)}
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path <path
fillRule="evenodd" 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" 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" clipRule="evenodd"
/> />
</svg> </svg>
</button> </button>
</div> </div>
</div>
))}
{sortedSegments.length === 0 && (
<div className="empty-message">No segments created yet. Use the split button to create segments.</div>
)}
</div> </div>
))} );
{sortedSegments.length === 0 && (
<div className="empty-message">
No segments created yet. Use the split button to create segments.
</div>
)}
</div>
);
}; };
export default ClipSegments; export default ClipSegments;

View File

@ -1,108 +1,108 @@
import "../styles/EditingTools.css"; import '../styles/EditingTools.css';
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react';
interface EditingToolsProps { interface EditingToolsProps {
onSplit: () => void; onSplit: () => void;
onReset: () => void; onReset: () => void;
onUndo: () => void; onUndo: () => void;
onRedo: () => void; onRedo: () => void;
onPlaySegments: () => void; onPlaySegments: () => void;
onPlay: () => void; onPlay: () => void;
canUndo: boolean; canUndo: boolean;
canRedo: boolean; canRedo: boolean;
isPlaying?: boolean; isPlaying?: boolean;
isPlayingSegments?: boolean; isPlayingSegments?: boolean;
} }
const EditingTools = ({ const EditingTools = ({
onSplit, onSplit,
onReset, onReset,
onUndo, onUndo,
onRedo, onRedo,
onPlaySegments, onPlaySegments,
onPlay, onPlay,
canUndo, canUndo,
canRedo, canRedo,
isPlaying = false, isPlaying = false,
isPlayingSegments = false isPlayingSegments = false,
}: EditingToolsProps) => { }: EditingToolsProps) => {
const [isSmallScreen, setIsSmallScreen] = useState(false); const [isSmallScreen, setIsSmallScreen] = useState(false);
useEffect(() => { useEffect(() => {
const checkScreenSize = () => { const checkScreenSize = () => {
setIsSmallScreen(window.innerWidth <= 640); setIsSmallScreen(window.innerWidth <= 640);
};
checkScreenSize();
window.addEventListener('resize', checkScreenSize);
return () => window.removeEventListener('resize', checkScreenSize);
}, []);
// Handle play button click with iOS fix
const handlePlay = () => {
// Ensure lastSeekedPosition is used when play is clicked
if (typeof window !== 'undefined') {
console.log('Play button clicked, current lastSeekedPosition:', window.lastSeekedPosition);
}
// Call the original handler
onPlay();
}; };
checkScreenSize(); return (
window.addEventListener("resize", checkScreenSize); <div className="editing-tools-container">
return () => window.removeEventListener("resize", checkScreenSize); <div className="flex-container single-row">
}, []); {/* Left side - Play buttons group */}
<div className="button-group play-buttons-group">
{/* Play Segments button */}
<button
className={`button segments-button`}
onClick={onPlaySegments}
data-tooltip={
isPlayingSegments ? 'Stop segments playback' : 'Play segments in one continuous flow'
}
style={{ fontSize: '0.875rem' }}
>
{isPlayingSegments ? (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="10" y1="15" x2="10" y2="9" />
<line x1="14" y1="15" x2="14" y2="9" />
</svg>
<span className="full-text">Stop Preview</span>
<span className="short-text">Stop Preview</span>
</>
) : (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polygon points="10 8 16 12 10 16 10 8" />
</svg>
<span className="full-text">Play Preview</span>
<span className="short-text">Play Preview</span>
</>
)}
</button>
// Handle play button click with iOS fix {/* Play Preview button */}
const handlePlay = () => { {/* <button
// Ensure lastSeekedPosition is used when play is clicked
if (typeof window !== "undefined") {
console.log("Play button clicked, current lastSeekedPosition:", window.lastSeekedPosition);
}
// Call the original handler
onPlay();
};
return (
<div className="editing-tools-container">
<div className="flex-container single-row">
{/* Left side - Play buttons group */}
<div className="button-group play-buttons-group">
{/* Play Segments button */}
<button
className={`button segments-button`}
onClick={onPlaySegments}
data-tooltip={
isPlayingSegments ? "Stop segments playback" : "Play segments in one continuous flow"
}
style={{ fontSize: "0.875rem" }}
>
{isPlayingSegments ? (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="10" y1="15" x2="10" y2="9" />
<line x1="14" y1="15" x2="14" y2="9" />
</svg>
<span className="full-text">Stop Preview</span>
<span className="short-text">Stop Preview</span>
</>
) : (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polygon points="10 8 16 12 10 16 10 8" />
</svg>
<span className="full-text">Play Preview</span>
<span className="short-text">Play Preview</span>
</>
)}
</button>
{/* Play Preview button */}
{/* <button
className="button preview-button" className="button preview-button"
onClick={onPreview} onClick={onPreview}
data-tooltip={isPreviewMode ? "Stop preview playback" : "Play only segments (skips gaps between segments)"} data-tooltip={isPreviewMode ? "Stop preview playback" : "Play only segments (skips gaps between segments)"}
@ -130,56 +130,56 @@ const EditingTools = ({
)} )}
</button> */} </button> */}
{/* Standard Play button (only shown when not in segments playback on small screens) */} {/* Standard Play button (only shown when not in segments playback on small screens) */}
{(!isPlayingSegments || !isSmallScreen) && ( {(!isPlayingSegments || !isSmallScreen) && (
<button <button
className={`button play-button ${isPlayingSegments ? "greyed-out" : ""}`} className={`button play-button ${isPlayingSegments ? 'greyed-out' : ''}`}
onClick={handlePlay} onClick={handlePlay}
data-tooltip={isPlaying ? "Pause video" : "Play full video"} data-tooltip={isPlaying ? 'Pause video' : 'Play full video'}
style={{ fontSize: "0.875rem" }} style={{ fontSize: '0.875rem' }}
disabled={isPlayingSegments} disabled={isPlayingSegments}
> >
{isPlaying && !isPlayingSegments ? ( {isPlaying && !isPlayingSegments ? (
<> <>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
> >
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
<line x1="10" y1="15" x2="10" y2="9" /> <line x1="10" y1="15" x2="10" y2="9" />
<line x1="14" y1="15" x2="14" y2="9" /> <line x1="14" y1="15" x2="14" y2="9" />
</svg> </svg>
<span className="full-text">Pause</span> <span className="full-text">Pause</span>
<span className="short-text">Pause</span> <span className="short-text">Pause</span>
</> </>
) : ( ) : (
<> <>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
> >
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
<polygon points="10 8 16 12 10 16 10 8" /> <polygon points="10 8 16 12 10 16 10 8" />
</svg> </svg>
<span className="full-text">Play</span> <span className="full-text">Play</span>
<span className="short-text">Play</span> <span className="short-text">Play</span>
</> </>
)} )}
</button> </button>
)} )}
{/* Segments Playback message (replaces play button during segments playback) */} {/* Segments Playback message (replaces play button during segments playback) */}
{/* {isPlayingSegments && !isSmallScreen && ( {/* {isPlayingSegments && !isSmallScreen && (
<div className="segments-playback-message"> <div className="segments-playback-message">
<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">
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
@ -190,8 +190,8 @@ const EditingTools = ({
</div> </div>
)} */} )} */}
{/* Preview mode message (replaces play button) */} {/* Preview mode message (replaces play button) */}
{/* {isPreviewMode && ( {/* {isPreviewMode && (
<div className="preview-mode-message"> <div className="preview-mode-message">
<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">
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
@ -201,72 +201,72 @@ const EditingTools = ({
Preview Mode Preview Mode
</div> </div>
)} */} )} */}
</div> </div>
{/* Right side - Editing tools */} {/* Right side - Editing tools */}
<div className="button-group secondary"> <div className="button-group secondary">
<button <button
className="button" className="button"
aria-label="Undo" aria-label="Undo"
data-tooltip={isPlayingSegments ? "Disabled during preview" : "Undo last action"} data-tooltip={isPlayingSegments ? 'Disabled during preview' : 'Undo last action'}
disabled={!canUndo || isPlayingSegments} disabled={!canUndo || isPlayingSegments}
onClick={onUndo} onClick={onUndo}
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
> >
<path d="M9 14 4 9l5-5" /> <path d="M9 14 4 9l5-5" />
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11" /> <path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11" />
</svg> </svg>
<span className="button-text">Undo</span> <span className="button-text">Undo</span>
</button> </button>
<button <button
className="button" className="button"
aria-label="Redo" aria-label="Redo"
data-tooltip={isPlayingSegments ? "Disabled during preview" : "Redo last undone action"} data-tooltip={isPlayingSegments ? 'Disabled during preview' : 'Redo last undone action'}
disabled={!canRedo || isPlayingSegments} disabled={!canRedo || isPlayingSegments}
onClick={onRedo} onClick={onRedo}
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
> >
<path d="m15 14 5-5-5-5" /> <path d="m15 14 5-5-5-5" />
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5v0A5.5 5.5 0 0 0 9.5 20H13" /> <path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5v0A5.5 5.5 0 0 0 9.5 20H13" />
</svg> </svg>
<span className="button-text">Redo</span> <span className="button-text">Redo</span>
</button> </button>
<div className="divider"></div> <div className="divider"></div>
<button <button
className="button" className="button"
onClick={onReset} onClick={onReset}
data-tooltip={isPlayingSegments ? "Disabled during preview" : "Reset to full video"} data-tooltip={isPlayingSegments ? 'Disabled during preview' : 'Reset to full video'}
disabled={isPlayingSegments} disabled={isPlayingSegments}
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path <path
fillRule="evenodd" fillRule="evenodd"
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
clipRule="evenodd" clipRule="evenodd"
/> />
</svg> </svg>
<span className="reset-text">Reset</span> <span className="reset-text">Reset</span>
</button> </button>
</div>
</div>
</div> </div>
</div> );
</div>
);
}; };
export default EditingTools; export default EditingTools;

View File

@ -1,55 +1,55 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from 'react';
import "../styles/IOSPlayPrompt.css"; import '../styles/IOSPlayPrompt.css';
interface MobilePlayPromptProps { interface MobilePlayPromptProps {
videoRef: React.RefObject<HTMLVideoElement>; videoRef: React.RefObject<HTMLVideoElement>;
onPlay: () => void; onPlay: () => void;
} }
const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay }) => { const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay }) => {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
// Check if the device is mobile // Check if the device is mobile
useEffect(() => { useEffect(() => {
const checkIsMobile = () => { const checkIsMobile = () => {
// More comprehensive check for mobile/tablet devices // More comprehensive check for mobile/tablet devices
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test( return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(
navigator.userAgent navigator.userAgent
); );
};
// Always show for mobile devices on each visit
const isMobile = checkIsMobile();
setIsVisible(isMobile);
}, []);
// Close the prompt when video plays
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handlePlay = () => {
// Just close the prompt when video plays
setIsVisible(false);
};
video.addEventListener('play', handlePlay);
return () => {
video.removeEventListener('play', handlePlay);
};
}, [videoRef]);
const handlePlayClick = () => {
onPlay();
// Prompt will be closed by the play event handler
}; };
// Always show for mobile devices on each visit if (!isVisible) return null;
const isMobile = checkIsMobile();
setIsVisible(isMobile);
}, []);
// Close the prompt when video plays return (
useEffect(() => { <div className="mobile-play-prompt-overlay">
const video = videoRef.current; <div className="mobile-play-prompt">
if (!video) return; {/* <h3>Mobile Device Notice</h3>
const handlePlay = () => {
// Just close the prompt when video plays
setIsVisible(false);
};
video.addEventListener("play", handlePlay);
return () => {
video.removeEventListener("play", handlePlay);
};
}, [videoRef]);
const handlePlayClick = () => {
onPlay();
// Prompt will be closed by the play event handler
};
if (!isVisible) return null;
return (
<div className="mobile-play-prompt-overlay">
<div className="mobile-play-prompt">
{/* <h3>Mobile Device Notice</h3>
<p> <p>
For the best video editing experience on mobile devices, you need to <strong>play the video first</strong> before For the best video editing experience on mobile devices, you need to <strong>play the video first</strong> before
@ -65,12 +65,12 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
</ol> </ol>
</div> */} </div> */}
<button className="mobile-play-button" onClick={handlePlayClick}> <button className="mobile-play-button" onClick={handlePlayClick}>
Click to start editing... Click to start editing...
</button> </button>
</div> </div>
</div> </div>
); );
}; };
export default MobilePlayPrompt; export default MobilePlayPrompt;

View File

@ -1,184 +1,184 @@
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from 'react';
import { formatTime } from "@/lib/timeUtils"; import { formatTime } from '@/lib/timeUtils';
import "../styles/IOSVideoPlayer.css"; import '../styles/IOSVideoPlayer.css';
interface IOSVideoPlayerProps { interface IOSVideoPlayerProps {
videoRef: React.RefObject<HTMLVideoElement>; videoRef: React.RefObject<HTMLVideoElement>;
currentTime: number; currentTime: number;
duration: number; duration: number;
} }
const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => { const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => {
const [videoUrl, setVideoUrl] = useState<string>(""); const [videoUrl, setVideoUrl] = useState<string>('');
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null); const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
// Refs for hold-to-continue functionality // Refs for hold-to-continue functionality
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null); const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
const decrementIntervalRef = useRef<NodeJS.Timeout | null>(null); const decrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Clean up intervals on unmount // Clean up intervals on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current); if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current); 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 (source && source.src) {
setVideoUrl(source.src);
}
} else {
// Fallback to sample video if needed
setVideoUrl('/videos/sample-video.mp4');
}
}, [videoRef]);
// Function to jump 15 seconds backward
const jumpBackward15 = () => {
if (iosVideoRef) {
const newTime = Math.max(0, iosVideoRef.currentTime - 15);
iosVideoRef.currentTime = newTime;
}
}; };
}, []);
// Get the video source URL from the main player // Function to jump 15 seconds forward
useEffect(() => { const jumpForward15 = () => {
if (videoRef.current && videoRef.current.querySelector("source")) { if (iosVideoRef) {
const source = videoRef.current.querySelector("source") as HTMLSourceElement; const newTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 15);
if (source && source.src) { iosVideoRef.currentTime = newTime;
setVideoUrl(source.src); }
} };
} else {
// Fallback to sample video if needed
setVideoUrl("/videos/sample-video-10m.mp4");
}
}, [videoRef]);
// Function to jump 15 seconds backward // Start continuous 50ms increment when button is held
const jumpBackward15 = () => { const startIncrement = (e: React.MouseEvent | React.TouchEvent) => {
if (iosVideoRef) { // Prevent default to avoid text selection
const newTime = Math.max(0, iosVideoRef.currentTime - 15); e.preventDefault();
iosVideoRef.currentTime = newTime;
}
};
// Function to jump 15 seconds forward if (!iosVideoRef) return;
const jumpForward15 = () => { if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
if (iosVideoRef) {
const newTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 15);
iosVideoRef.currentTime = newTime;
}
};
// Start continuous 50ms increment when button is held // First immediate adjustment
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) {
iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05); iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05);
}
}, 100);
};
// Stop continuous increment // Setup continuous adjustment
const stopIncrement = () => { incrementIntervalRef.current = setInterval(() => {
if (incrementIntervalRef.current) { if (iosVideoRef) {
clearInterval(incrementIntervalRef.current); iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05);
incrementIntervalRef.current = null; }
} }, 100);
}; };
// Start continuous 50ms decrement when button is held // Stop continuous increment
const startDecrement = (e: React.MouseEvent | React.TouchEvent) => { const stopIncrement = () => {
// Prevent default to avoid text selection if (incrementIntervalRef.current) {
e.preventDefault(); clearInterval(incrementIntervalRef.current);
incrementIntervalRef.current = null;
}
};
if (!iosVideoRef) return; // Start continuous 50ms decrement when button is held
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current); const startDecrement = (e: React.MouseEvent | React.TouchEvent) => {
// Prevent default to avoid text selection
e.preventDefault();
// First immediate adjustment if (!iosVideoRef) return;
iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05); if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
// Setup continuous adjustment // First immediate adjustment
decrementIntervalRef.current = setInterval(() => {
if (iosVideoRef) {
iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05); iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
}
}, 100);
};
// Stop continuous decrement // Setup continuous adjustment
const stopDecrement = () => { decrementIntervalRef.current = setInterval(() => {
if (decrementIntervalRef.current) { if (iosVideoRef) {
clearInterval(decrementIntervalRef.current); iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
decrementIntervalRef.current = null; }
} }, 100);
}; };
return ( // Stop continuous decrement
<div className="ios-video-player-container"> const stopDecrement = () => {
{/* Current Time / Duration Display */} if (decrementIntervalRef.current) {
<div className="ios-time-display mb-2"> clearInterval(decrementIntervalRef.current);
<span className="text-sm"> decrementIntervalRef.current = null;
{formatTime(currentTime)} / {formatTime(duration)} }
</span> };
</div>
{/* iOS-optimized Video Element with Native Controls */} return (
<video <div className="ios-video-player-container">
ref={(ref) => setIosVideoRef(ref)} {/* Current Time / Duration Display */}
className="w-full rounded-md" <div className="ios-time-display mb-2">
src={videoUrl} <span className="text-sm">
controls {formatTime(currentTime)} / {formatTime(duration)}
playsInline </span>
webkit-playsinline="true" </div>
x-webkit-airplay="allow"
preload="auto"
crossOrigin="anonymous"
>
<source src={videoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p>
</video>
{/* iOS Video Skip Controls */} {/* iOS-optimized Video Element with Native Controls */}
<div className="ios-skip-controls mt-3 flex justify-center gap-4"> <video
<button ref={(ref) => setIosVideoRef(ref)}
onClick={jumpBackward15} className="w-full rounded-md"
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md" src={videoUrl}
> controls
-15s playsInline
</button> webkit-playsinline="true"
<button x-webkit-airplay="allow"
onClick={jumpForward15} preload="auto"
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md" crossOrigin="anonymous"
> >
+15s <source src={videoUrl} type="video/mp4" />
</button> <p>Your browser doesn't support HTML5 video.</p>
</div> </video>
{/* iOS Fine Control Buttons */} {/* iOS Video Skip Controls */}
<div className="ios-fine-controls mt-2 flex justify-center gap-4"> <div className="ios-skip-controls mt-3 flex justify-center gap-4">
<button <button
onMouseDown={startDecrement} onClick={jumpBackward15}
onTouchStart={startDecrement} className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
onMouseUp={stopDecrement} >
onMouseLeave={stopDecrement} -15s
onTouchEnd={stopDecrement} </button>
onTouchCancel={stopDecrement} <button
className="ios-control-btn flex items-center justify-center bg-indigo-600 text-white py-2 px-4 rounded-md no-select" onClick={jumpForward15}
> className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
-50ms >
</button> +15s
<button </button>
onMouseDown={startIncrement} </div>
onTouchStart={startIncrement}
onMouseUp={stopIncrement}
onMouseLeave={stopIncrement}
onTouchEnd={stopIncrement}
onTouchCancel={stopIncrement}
className="ios-control-btn flex items-center justify-center bg-indigo-600 text-white py-2 px-4 rounded-md no-select"
>
+50ms
</button>
</div>
<div className="ios-note mt-2 text-xs text-gray-500"> {/* iOS Fine Control Buttons */}
<p>This player uses native iOS controls for better compatibility with iOS devices.</p> <div className="ios-fine-controls mt-2 flex justify-center gap-4">
</div> <button
</div> onMouseDown={startDecrement}
); onTouchStart={startDecrement}
onMouseUp={stopDecrement}
onMouseLeave={stopDecrement}
onTouchEnd={stopDecrement}
onTouchCancel={stopDecrement}
className="ios-control-btn flex items-center justify-center bg-indigo-600 text-white py-2 px-4 rounded-md no-select"
>
-50ms
</button>
<button
onMouseDown={startIncrement}
onTouchStart={startIncrement}
onMouseUp={stopIncrement}
onMouseLeave={stopIncrement}
onTouchEnd={stopIncrement}
onTouchCancel={stopIncrement}
className="ios-control-btn flex items-center justify-center bg-indigo-600 text-white py-2 px-4 rounded-md no-select"
>
+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>
</div>
);
}; };
export default IOSVideoPlayer; export default IOSVideoPlayer;

View File

@ -1,74 +1,74 @@
import React, { useEffect } from "react"; import React, { useEffect } from 'react';
import "../styles/Modal.css"; import '../styles/Modal.css';
interface ModalProps { interface ModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
title: string; title: string;
children: React.ReactNode; children: React.ReactNode;
actions?: React.ReactNode; 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 // Close modal when Escape key is pressed
useEffect(() => { useEffect(() => {
const handleEscapeKey = (event: KeyboardEvent) => { const handleEscapeKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && isOpen) { if (event.key === 'Escape' && isOpen) {
onClose(); onClose();
} }
};
document.addEventListener('keydown', handleEscapeKey);
// Disable body scrolling when modal is open
if (isOpen) {
document.body.style.overflow = 'hidden';
}
return () => {
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) {
onClose();
}
}; };
document.addEventListener("keydown", handleEscapeKey); return (
<div className="modal-overlay" onClick={handleClickOutside}>
<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"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
// Disable body scrolling when modal is open <div className="modal-content">{children}</div>
if (isOpen) {
document.body.style.overflow = "hidden";
}
return () => { {actions && <div className="modal-actions">{actions}</div>}
document.removeEventListener("keydown", handleEscapeKey); </div>
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) {
onClose();
}
};
return (
<div className="modal-overlay" onClick={handleClickOutside}>
<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"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div> </div>
);
<div className="modal-content">{children}</div>
{actions && <div className="modal-actions">{actions}</div>}
</div>
</div>
);
}; };
export default Modal; export default Modal;

View File

@ -1,452 +1,469 @@
import React, { useRef, useEffect, useState } from "react"; import React, { useRef, useEffect, useState } from 'react';
import { formatTime, formatDetailedTime } from "@/lib/timeUtils"; import { formatTime, formatDetailedTime } from '@/lib/timeUtils';
import logger from "../lib/logger"; import logger from '../lib/logger';
import "../styles/VideoPlayer.css"; import '../styles/VideoPlayer.css';
interface VideoPlayerProps { interface VideoPlayerProps {
videoRef: React.RefObject<HTMLVideoElement>; videoRef: React.RefObject<HTMLVideoElement>;
currentTime: number; currentTime: number;
duration: number; duration: number;
isPlaying: boolean; isPlaying: boolean;
isMuted?: boolean; isMuted?: boolean;
onPlayPause: () => void; onPlayPause: () => void;
onSeek: (time: number) => void; onSeek: (time: number) => void;
onToggleMute?: () => void; onToggleMute?: () => void;
} }
const VideoPlayer: React.FC<VideoPlayerProps> = ({ const VideoPlayer: React.FC<VideoPlayerProps> = ({
videoRef, videoRef,
currentTime, currentTime,
duration, duration,
isPlaying, isPlaying,
isMuted = false, isMuted = false,
onPlayPause, onPlayPause,
onSeek, onSeek,
onToggleMute onToggleMute,
}) => { }) => {
const progressRef = useRef<HTMLDivElement>(null); const progressRef = useRef<HTMLDivElement>(null);
const [isIOS, setIsIOS] = useState(false); const [isIOS, setIsIOS] = useState(false);
const [hasInitialized, setHasInitialized] = useState(false); const [hasInitialized, setHasInitialized] = useState(false);
const [lastPosition, setLastPosition] = useState<number | null>(null); const [lastPosition, setLastPosition] = useState<number | null>(null);
const [isDraggingProgress, setIsDraggingProgress] = useState(false); const [isDraggingProgress, setIsDraggingProgress] = useState(false);
const isDraggingProgressRef = useRef(false); const isDraggingProgressRef = useRef(false);
const [tooltipPosition, setTooltipPosition] = useState({ x: 0 }); const [tooltipPosition, setTooltipPosition] = useState({
const [tooltipTime, setTooltipTime] = useState(0); x: 0,
});
const [tooltipTime, setTooltipTime] = useState(0);
const sampleVideoUrl = const sampleVideoUrl =
(typeof window !== "undefined" && (window as any).MEDIA_DATA?.videoUrl) || (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.videoUrl) || '/videos/sample-video.mp4';
"/videos/sample-video-10m.mp4";
// Detect iOS device // Detect iOS device
useEffect(() => { useEffect(() => {
const checkIOS = () => { const checkIOS = () => {
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera; const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream; return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
}; };
setIsIOS(checkIOS()); setIsIOS(checkIOS());
// Check if video was previously initialized // Check if video was previously initialized
if (typeof window !== "undefined") { if (typeof window !== 'undefined') {
const wasInitialized = localStorage.getItem("video_initialized") === "true"; const wasInitialized = localStorage.getItem('video_initialized') === 'true';
setHasInitialized(wasInitialized); setHasInitialized(wasInitialized);
}
}, []);
// Update initialized state when video plays
useEffect(() => {
if (isPlaying && !hasInitialized) {
setHasInitialized(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;
if (!video) return;
// These attributes need to be set directly on the DOM element
// for iOS Safari to respect inline playback
video.setAttribute("playsinline", "true");
video.setAttribute("webkit-playsinline", "true");
video.setAttribute("x-webkit-airplay", "allow");
// Store the last known good position for iOS
const handleTimeUpdate = () => {
if (!isDraggingProgressRef.current) {
setLastPosition(video.currentTime);
if (typeof window !== "undefined") {
window.lastSeekedPosition = video.currentTime;
} }
} }, []);
// Update initialized state when video plays
useEffect(() => {
if (isPlaying && !hasInitialized) {
setHasInitialized(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;
if (!video) return;
// These attributes need to be set directly on the DOM element
// for iOS Safari to respect inline playback
video.setAttribute('playsinline', 'true');
video.setAttribute('webkit-playsinline', 'true');
video.setAttribute('x-webkit-airplay', 'allow');
// Store the last known good position for iOS
const handleTimeUpdate = () => {
if (!isDraggingProgressRef.current) {
setLastPosition(video.currentTime);
if (typeof window !== 'undefined') {
window.lastSeekedPosition = video.currentTime;
}
}
};
// Handle iOS-specific play/pause state
const handlePlay = () => {
logger.debug('Video play event fired');
if (isIOS) {
setHasInitialized(true);
localStorage.setItem('video_initialized', 'true');
}
};
const handlePause = () => {
logger.debug('Video pause event fired');
};
video.addEventListener('timeupdate', handleTimeUpdate);
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
return () => {
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
};
}, [videoRef, isIOS, isDraggingProgressRef]);
// Save current time to lastPosition when it changes (from external seeking)
useEffect(() => {
setLastPosition(currentTime);
}, [currentTime]);
// Jump 10 seconds forward
const handleForward = () => {
const newTime = Math.min(currentTime + 10, duration);
onSeek(newTime);
setLastPosition(newTime);
}; };
// Handle iOS-specific play/pause state // Jump 10 seconds backward
const handlePlay = () => { const handleBackward = () => {
logger.debug("Video play event fired"); const newTime = Math.max(currentTime - 10, 0);
if (isIOS) { onSeek(newTime);
setHasInitialized(true); setLastPosition(newTime);
localStorage.setItem("video_initialized", "true");
}
}; };
const handlePause = () => { // Calculate progress percentage
logger.debug("Video pause event fired"); 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.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}; };
video.addEventListener("timeupdate", handleTimeUpdate); // Handle progress dragging for both mouse and touch events
video.addEventListener("play", handlePlay); const handleProgressDrag = (e: MouseEvent | React.MouseEvent) => {
video.addEventListener("pause", handlePause); if (!progressRef.current) return;
return () => { const rect = progressRef.current.getBoundingClientRect();
video.removeEventListener("timeupdate", handleTimeUpdate); const clickPosition = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
video.removeEventListener("play", handlePlay); const seekTime = duration * clickPosition;
video.removeEventListener("pause", handlePause);
};
}, [videoRef, isIOS, isDraggingProgressRef]);
// Save current time to lastPosition when it changes (from external seeking) // Update tooltip position and time
useEffect(() => { setTooltipPosition({
setLastPosition(currentTime); x: e.clientX,
}, [currentTime]); });
setTooltipTime(seekTime);
// Jump 10 seconds forward // Store position locally for iOS Safari - critical for timeline seeking
const handleForward = () => { setLastPosition(seekTime);
const newTime = Math.min(currentTime + 10, duration);
onSeek(newTime);
setLastPosition(newTime);
};
// Jump 10 seconds backward // Also store globally for integration with other components
const handleBackward = () => { if (typeof window !== 'undefined') {
const newTime = Math.max(currentTime - 10, 0); (window as any).lastSeekedPosition = seekTime;
onSeek(newTime); }
setLastPosition(newTime);
};
// Calculate progress percentage onSeek(seekTime);
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 = () => { // Handle touch events for progress bar
setIsDraggingProgress(false); const handleProgressTouchStart = (e: React.TouchEvent) => {
isDraggingProgressRef.current = false; if (!progressRef.current || !e.touches[0]) return;
document.removeEventListener("mousemove", handleMouseMove); e.preventDefault();
document.removeEventListener("mouseup", handleMouseUp);
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.addEventListener('touchmove', handleTouchMove, {
passive: false,
});
document.addEventListener('touchend', handleTouchEnd);
document.addEventListener('touchcancel', handleTouchEnd);
}; };
document.addEventListener("mousemove", handleMouseMove); // Handle touch dragging on progress bar
document.addEventListener("mouseup", handleMouseUp); const handleProgressTouchMove = (e: TouchEvent | React.TouchEvent) => {
}; if (!progressRef.current) return;
// Handle progress dragging for both mouse and touch events // Get the touch coordinates
const handleProgressDrag = (e: MouseEvent | React.MouseEvent) => { const touch = 'touches' in e ? e.touches[0] : null;
if (!progressRef.current) return; if (!touch) return;
const rect = progressRef.current.getBoundingClientRect(); e.preventDefault(); // Prevent scrolling while dragging
const clickPosition = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const seekTime = duration * clickPosition;
// Update tooltip position and time const rect = progressRef.current.getBoundingClientRect();
setTooltipPosition({ x: e.clientX }); const touchPosition = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
setTooltipTime(seekTime); const seekTime = duration * touchPosition;
// Store position locally for iOS Safari - critical for timeline seeking // Update tooltip position and time
setLastPosition(seekTime); setTooltipPosition({
x: touch.clientX,
});
setTooltipTime(seekTime);
// Also store globally for integration with other components // Store position for iOS Safari
if (typeof window !== "undefined") { setLastPosition(seekTime);
(window as any).lastSeekedPosition = seekTime;
}
onSeek(seekTime); // Also store globally for integration with other components
}; if (typeof window !== 'undefined') {
(window as any).lastSeekedPosition = seekTime;
}
// Handle touch events for progress bar onSeek(seekTime);
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 = () => { // Handle click on progress bar (for non-drag interactions)
setIsDraggingProgress(false); const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
isDraggingProgressRef.current = false; // If we're already dragging, don't handle the click
document.removeEventListener("touchmove", handleTouchMove); if (isDraggingProgress) return;
document.removeEventListener("touchend", handleTouchEnd);
document.removeEventListener("touchcancel", handleTouchEnd); 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') {
(window as any).lastSeekedPosition = seekTime;
}
onSeek(seekTime);
}
}; };
document.addEventListener("touchmove", handleTouchMove, { passive: false }); // Handle toggling fullscreen
document.addEventListener("touchend", handleTouchEnd); const handleFullscreen = () => {
document.addEventListener("touchcancel", handleTouchEnd); if (videoRef.current) {
}; if (document.fullscreenElement) {
document.exitFullscreen();
} else {
videoRef.current.requestFullscreen();
}
}
};
// Handle touch dragging on progress bar // Handle click on video to play/pause
const handleProgressTouchMove = (e: TouchEvent | React.TouchEvent) => { const handleVideoClick = () => {
if (!progressRef.current) return; const video = videoRef.current;
if (!video) return;
// Get the touch coordinates // If the video is paused, we want to play it
const touch = "touches" in e ? e.touches[0] : null; if (video.paused) {
if (!touch) return; // 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);
e.preventDefault(); // Prevent scrolling while dragging // First, seek to the position
video.currentTime = lastPosition;
const rect = progressRef.current.getBoundingClientRect(); // Use a small timeout to ensure seeking is complete before play
const touchPosition = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width)); setTimeout(() => {
const seekTime = duration * touchPosition; if (videoRef.current) {
// Try to play with proper promise handling
videoRef.current
.play()
.then(() => {
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);
});
}
}, 50);
} else {
// Normal play (non-iOS or no remembered position)
video
.play()
.then(() => {
logger.debug('Normal: Play started successfully');
onPlayPause(); // Update parent state after successful play
})
.catch((err) => {
console.error('Error playing video:', err);
});
}
} else {
// If playing, pause and update state
video.pause();
onPlayPause();
}
};
// Update tooltip position and time return (
setTooltipPosition({ x: touch.clientX }); <div className="video-player-container">
setTooltipTime(seekTime); <video
ref={videoRef}
// Store position for iOS Safari preload="auto"
setLastPosition(seekTime); crossOrigin="anonymous"
onClick={handleVideoClick}
// Also store globally for integration with other components playsInline
if (typeof window !== "undefined") { webkit-playsinline="true"
(window as any).lastSeekedPosition = seekTime; x-webkit-airplay="allow"
} controls={false}
muted={isMuted}
onSeek(seekTime);
};
// Handle click on progress bar (for non-drag interactions)
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") {
(window as any).lastSeekedPosition = seekTime;
}
onSeek(seekTime);
}
};
// Handle toggling fullscreen
const handleFullscreen = () => {
if (videoRef.current) {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
videoRef.current.requestFullscreen();
}
}
};
// Handle click on video to play/pause
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()
.then(() => {
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);
});
}
}, 50);
} else {
// Normal play (non-iOS or no remembered position)
video
.play()
.then(() => {
logger.debug("Normal: Play started successfully");
onPlayPause(); // Update parent state after successful play
})
.catch((err) => {
console.error("Error playing video:", err);
});
}
} else {
// If playing, pause and update state
video.pause();
onPlayPause();
}
};
return (
<div className="video-player-container">
<video
ref={videoRef}
preload="auto"
crossOrigin="anonymous"
onClick={handleVideoClick}
playsInline
webkit-playsinline="true"
x-webkit-airplay="allow"
controls={false}
muted={isMuted}
>
<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>
)}
{/* Play/Pause Indicator (shows based on current state) */}
<div className={`play-pause-indicator ${isPlaying ? "pause-icon" : "play-icon"}`}></div>
{/* Video Controls Overlay */}
<div className="video-controls">
{/* Time and Duration */}
<div className="video-time-display">
<span className="video-current-time">{formatTime(currentTime)}</span>
<span className="video-duration">/ {formatTime(duration)}</span>
</div>
{/* Progress Bar with enhanced dragging */}
<div
ref={progressRef}
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>
{/* Floating time tooltip when dragging */}
{isDraggingProgress && (
<div
className="video-time-tooltip"
style={{
left: `${tooltipPosition.x}px`,
transform: "translateX(-50%)"
}}
> >
{formatDetailedTime(tooltipTime)} <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>
)}
{/* Play/Pause Indicator (shows based on current state) */}
<div className={`play-pause-indicator ${isPlaying ? 'pause-icon' : 'play-icon'}`}></div>
{/* Video Controls Overlay */}
<div className="video-controls">
{/* Time and Duration */}
<div className="video-time-display">
<span className="video-current-time">{formatTime(currentTime)}</span>
<span className="video-duration">/ {formatTime(duration)}</span>
</div>
{/* Progress Bar with enhanced dragging */}
<div
ref={progressRef}
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>
{/* Floating time tooltip when dragging */}
{isDraggingProgress && (
<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"
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"
>
<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>
<line x1="12" y1="19" x2="12" y2="23"></line>
<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"
>
<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"
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"
/>
</svg>
</button>
</div>
</div> </div>
)}
</div> </div>
);
{/* Controls - Mute and Fullscreen buttons */}
<div className="video-controls-buttons">
{/* Mute/Unmute Button */}
{onToggleMute && (
<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"
>
<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>
<line x1="12" y1="19" x2="12" y2="23"></line>
<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"
>
<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"
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"
/>
</svg>
</button>
</div>
</div>
</div>
);
}; };
export default VideoPlayer; export default VideoPlayer;

View File

@ -3,29 +3,29 @@
* but always shows errors, warnings, and info messages. * but always shows errors, warnings, and info messages.
*/ */
const logger = { const logger = {
/** /**
* Logs debug messages only in development environment * Logs debug messages only in development environment
*/ */
debug: (...args: any[]) => { debug: (...args: any[]) => {
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === 'development') {
console.debug(...args); console.debug(...args);
} }
}, },
/** /**
* Always logs error messages * Always logs error messages
*/ */
error: (...args: any[]) => console.error(...args), error: (...args: any[]) => console.error(...args),
/** /**
* Always logs warning messages * Always logs warning messages
*/ */
warn: (...args: any[]) => console.warn(...args), warn: (...args: any[]) => console.warn(...args),
/** /**
* Always logs info messages * Always logs info messages
*/ */
info: (...args: any[]) => console.info(...args) info: (...args: any[]) => console.info(...args),
}; };
export default logger; export default logger;

View File

@ -1,55 +1,51 @@
import { QueryClient, QueryFunction } from "@tanstack/react-query"; import { QueryClient, QueryFunction } from '@tanstack/react-query';
async function throwIfResNotOk(res: Response) { async function throwIfResNotOk(res: Response) {
if (!res.ok) { if (!res.ok) {
const text = (await res.text()) || res.statusText; const text = (await res.text()) || res.statusText;
throw new Error(`${res.status}: ${text}`); throw new Error(`${res.status}: ${text}`);
} }
} }
export async function apiRequest( export async function apiRequest(method: string, url: string, data?: unknown | undefined): Promise<Response> {
method: string, const res = await fetch(url, {
url: string, method,
data?: unknown | undefined headers: data ? { 'Content-Type': 'application/json' } : {},
): Promise<Response> { body: data ? JSON.stringify(data) : undefined,
const res = await fetch(url, { credentials: 'include',
method,
headers: data ? { "Content-Type": "application/json" } : {},
body: data ? JSON.stringify(data) : undefined,
credentials: "include"
});
await throwIfResNotOk(res);
return res;
}
type UnauthorizedBehavior = "returnNull" | "throw";
export const getQueryFn: <T>(options: { on401: UnauthorizedBehavior }) => QueryFunction<T> =
({ on401: unauthorizedBehavior }) =>
async ({ queryKey }) => {
const res = await fetch(queryKey[0] as string, {
credentials: "include"
}); });
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
return null;
}
await throwIfResNotOk(res); await throwIfResNotOk(res);
return await res.json(); return res;
}; }
type UnauthorizedBehavior = 'returnNull' | 'throw';
export const getQueryFn: <T>(options: { on401: UnauthorizedBehavior }) => QueryFunction<T> =
({ on401: unauthorizedBehavior }) =>
async ({ queryKey }) => {
const res = await fetch(queryKey[0] as string, {
credentials: 'include',
});
if (unauthorizedBehavior === 'returnNull' && res.status === 401) {
return null;
}
await throwIfResNotOk(res);
return await res.json();
};
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
queryFn: getQueryFn({ on401: "throw" }), queryFn: getQueryFn({ on401: 'throw' }),
refetchInterval: false, refetchInterval: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
staleTime: Infinity, staleTime: Infinity,
retry: false retry: false,
},
mutations: {
retry: false,
},
}, },
mutations: {
retry: false
}
}
}); });

View File

@ -2,33 +2,33 @@
* Format seconds to HH:MM:SS.mmm format with millisecond precision * Format seconds to HH:MM:SS.mmm format with millisecond precision
*/ */
export const formatDetailedTime = (seconds: number): string => { export const formatDetailedTime = (seconds: number): string => {
if (isNaN(seconds)) return "00:00:00.000"; if (isNaN(seconds)) return '00:00:00.000';
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60); const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.floor(seconds % 60); const remainingSeconds = Math.floor(seconds % 60);
const milliseconds = Math.round((seconds % 1) * 1000); const milliseconds = Math.round((seconds % 1) * 1000);
const formattedHours = String(hours).padStart(2, "0"); const formattedHours = String(hours).padStart(2, '0');
const formattedMinutes = String(minutes).padStart(2, "0"); const formattedMinutes = String(minutes).padStart(2, '0');
const formattedSeconds = String(remainingSeconds).padStart(2, "0"); const formattedSeconds = String(remainingSeconds).padStart(2, '0');
const formattedMilliseconds = String(milliseconds).padStart(3, "0"); const formattedMilliseconds = String(milliseconds).padStart(3, '0');
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}`; return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}`;
}; };
/** /**
* Format seconds to MM:SS format - now uses the detailed format with hours and milliseconds * Format seconds to MM:SS format - now uses the detailed format with hours and milliseconds
*/ */
export const formatTime = (seconds: number): string => { export const formatTime = (seconds: number): string => {
// Use the detailed format instead of the old MM:SS format // Use the detailed format instead of the old MM:SS format
return formatDetailedTime(seconds); return formatDetailedTime(seconds);
}; };
/** /**
* Format seconds to HH:MM:SS format - now uses the detailed format with milliseconds * Format seconds to HH:MM:SS format - now uses the detailed format with milliseconds
*/ */
export const formatLongTime = (seconds: number): string => { export const formatLongTime = (seconds: number): string => {
// Use the detailed format // Use the detailed format
return formatDetailedTime(seconds); return formatDetailedTime(seconds);
}; };

View File

@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx"; import { clsx, type ClassValue } from 'clsx';
import { twMerge } from "tailwind-merge"; import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }

View File

@ -3,45 +3,42 @@
* Returns a CSS color based on the segment position * 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 // Use the time position to create different colors
// This gives each segment a different color without needing an image // This gives each segment a different color without needing an image
const position = Math.min(Math.max(time / (duration || 1), 0), 1); const position = Math.min(Math.max(time / (duration || 1), 0), 1);
// Calculate color based on position // Calculate color based on position
// Use an extremely light blue-based color palette // Use an extremely light blue-based color palette
const hue = 210; // Blue base const hue = 210; // Blue base
const saturation = 40 + Math.floor(position * 20); // 40-60% (less saturated) const saturation = 40 + Math.floor(position * 20); // 40-60% (less saturated)
const lightness = 85 + Math.floor(position * 8); // 85-93% (extremely light) const lightness = 85 + Math.floor(position * 8); // 85-93% (extremely light)
return `hsl(${hue}, ${saturation}%, ${lightness}%)`; return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}; };
/** /**
* Legacy function kept for compatibility * Legacy function kept for compatibility
* Now returns a data URL for a solid color square instead of a video thumbnail * Now returns a data URL for a solid color square instead of a video thumbnail
*/ */
export const generateThumbnail = async ( export const generateThumbnail = async (videoElement: HTMLVideoElement, time: number): Promise<string> => {
videoElement: HTMLVideoElement, return new Promise((resolve) => {
time: number // Create a small canvas for the solid color
): Promise<string> => { const canvas = document.createElement('canvas');
return new Promise((resolve) => { canvas.width = 10; // Much smaller - we only need a color
// Create a small canvas for the solid color canvas.height = 10;
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) { if (ctx) {
// Get the solid color based on time // Get the solid color based on time
const color = generateSolidColor(time, videoElement.duration); const color = generateSolidColor(time, videoElement.duration);
// Fill with solid color // Fill with solid color
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height);
} }
// Convert to data URL (much smaller now) // 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); resolve(dataUrl);
}); });
}; };

View File

@ -1,20 +1,20 @@
// API service for video trimming operations // API service for video trimming operations
interface TrimVideoRequest { interface TrimVideoRequest {
segments: { segments: {
startTime: string; startTime: string;
endTime: string; endTime: string;
name?: string; name?: string;
}[]; }[];
saveAsCopy?: boolean; saveAsCopy?: boolean;
saveIndividualSegments?: boolean; saveIndividualSegments?: boolean;
} }
interface TrimVideoResponse { interface TrimVideoResponse {
msg: string; msg: string;
url_redirect: string; url_redirect: string;
status?: number; // HTTP status code for success/error status?: number; // HTTP status code for success/error
error?: string; // Error message if status is not 200 error?: string; // Error message if status is not 200
} }
// Helper function to simulate delay // Helper function to simulate delay
@ -22,90 +22,87 @@ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// For now, we'll use a mock API that returns a promise // For now, we'll use a mock API that returns a promise
// This can be replaced with actual API calls later // This can be replaced with actual API calls later
export const trimVideo = async ( export const trimVideo = async (mediaId: string, data: TrimVideoRequest): Promise<TrimVideoResponse> => {
mediaId: string, try {
data: TrimVideoRequest // Attempt the real API call
): Promise<TrimVideoResponse> => { const response = await fetch(`/api/v1/media/${mediaId}/trim_video`, {
try { method: 'POST',
// Attempt the real API call headers: { 'Content-Type': 'application/json' },
const response = await fetch(`/api/v1/media/${mediaId}/trim_video`, { body: JSON.stringify(data),
method: "POST", });
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
});
if (!response.ok) { if (!response.ok) {
// For error responses, return with error status and message // For error responses, return with error status and message
if (response.status === 400) { if (response.status === 400) {
// Handle 400 Bad Request - return with error details // Handle 400 Bad Request - return with error details
try { try {
// Try to get error details from response // Try to get error details from response
const errorData = await response.json(); const errorData = await response.json();
return { return {
status: 400, status: 400,
error: errorData.error || "An error occurred during processing", error: errorData.error || 'An error occurred during processing',
msg: "Video Processing Error", msg: 'Video Processing Error',
url_redirect: "" url_redirect: '',
}; };
} catch (parseError) { } catch (parseError) {
// If can't parse response JSON, return generic error // If can't parse response JSON, return generic error
return { return {
status: 400, status: 400,
error: "An error occurred during video processing", error: 'An error occurred during video processing',
msg: "Video Processing Error", msg: 'Video Processing Error',
url_redirect: "" url_redirect: '',
}; };
}
} else if (response.status !== 404) {
// Handle other error responses
try {
// Try to get error details from response
const errorData = await response.json();
return {
status: response.status,
error: errorData.error || 'An error occurred during processing',
msg: 'Video Processing Error',
url_redirect: '',
};
} catch (parseError) {
// If can't parse response JSON, return generic error
return {
status: response.status,
error: 'An error occurred during video processing',
msg: 'Video Processing Error',
url_redirect: '',
};
}
} else {
// If endpoint not ready (404), return mock success response
await delay(1500); // Simulate 1.5 second server delay
return {
status: 200, // Mock success status
msg: 'Video Processed Successfully', // Updated per requirements
url_redirect: `./view?m=${mediaId}`,
};
}
} }
} else if (response.status !== 404) {
// Handle other error responses // Successful response
try { const jsonResponse = await response.json();
// Try to get error details from response return {
const errorData = await response.json(); status: 200,
return { msg: 'Video Processed Successfully', // Ensure the success message is correct
status: response.status, url_redirect: jsonResponse.url_redirect || `./view?m=${mediaId}`,
error: errorData.error || "An error occurred during processing", ...jsonResponse,
msg: "Video Processing Error", };
url_redirect: "" } catch (error) {
}; // For any fetch errors, return mock success response with delay
} catch (parseError) {
// If can't parse response JSON, return generic error
return {
status: response.status,
error: "An error occurred during video processing",
msg: "Video Processing Error",
url_redirect: ""
};
}
} else {
// If endpoint not ready (404), return mock success response
await delay(1500); // Simulate 1.5 second server delay await delay(1500); // Simulate 1.5 second server delay
return { return {
status: 200, // Mock success status status: 200, // Mock success status
msg: "Video Processed Successfully", // Updated per requirements msg: 'Video Processed Successfully', // Consistent with requirements
url_redirect: `./view?m=${mediaId}` url_redirect: `./view?m=${mediaId}`,
}; };
}
} }
// Successful response /* Mock implementation that simulates network latency
const jsonResponse = await response.json();
return {
status: 200,
msg: "Video Processed Successfully", // Ensure the success message is correct
url_redirect: jsonResponse.url_redirect || `./view?m=${mediaId}`,
...jsonResponse
};
} catch (error) {
// For any fetch errors, return mock success response with delay
await delay(1500); // Simulate 1.5 second server delay
return {
status: 200, // Mock success status
msg: "Video Processed Successfully", // Consistent with requirements
url_redirect: `./view?m=${mediaId}`
};
}
/* Mock implementation that simulates network latency
return new Promise((resolve) => { return new Promise((resolve) => {
setTimeout(() => { setTimeout(() => {
resolve({ resolve({

View File

@ -1,196 +1,196 @@
#video-editor-trim-root { #video-editor-trim-root {
/* Tooltip styles - only on desktop where hover is available */ /* Tooltip styles - only on desktop where hover is available */
@media (hover: hover) and (pointer: fine) { @media (hover: hover) and (pointer: fine) {
[data-tooltip] { [data-tooltip] {
position: relative; position: relative;
}
[data-tooltip]:before {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 5px;
background-color: rgba(0, 0, 0, 0.8);
color: white;
text-align: center;
padding: 5px 10px;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s,
visibility 0.2s;
z-index: 1000;
pointer-events: none;
}
[data-tooltip]:after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s,
visibility 0.2s;
pointer-events: none;
}
[data-tooltip]:hover:before,
[data-tooltip]:hover:after {
opacity: 1;
visibility: visible;
}
} }
[data-tooltip]:before { /* Hide button tooltips on touch devices */
content: attr(data-tooltip); @media (pointer: coarse) {
position: absolute; [data-tooltip]:before,
bottom: 100%; [data-tooltip]:after {
left: 50%; display: none !important;
transform: translateX(-50%); content: none !important;
margin-bottom: 5px; opacity: 0 !important;
background-color: rgba(0, 0, 0, 0.8); visibility: hidden !important;
color: white; pointer-events: none !important;
text-align: center; }
padding: 5px 10px; }
border-radius: 3px; .clip-segments-container {
font-size: 12px; margin-top: 1rem;
white-space: nowrap; background-color: white;
opacity: 0; border-radius: 0.5rem;
visibility: hidden; padding: 1rem;
transition: box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
opacity 0.2s,
visibility 0.2s;
z-index: 1000;
pointer-events: none;
} }
[data-tooltip]:after { .clip-segments-title {
content: ""; font-size: 0.875rem;
position: absolute; font-weight: 500;
bottom: 100%; color: var(--foreground, #333);
left: 50%; margin-bottom: 0.75rem;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s,
visibility 0.2s;
pointer-events: none;
} }
[data-tooltip]:hover:before, .segment-item {
[data-tooltip]:hover:after { display: flex;
opacity: 1; align-items: center;
visibility: visible; justify-content: space-between;
} padding: 0.5rem;
} border: 1px solid #e5e7eb;
border-radius: 0.25rem;
margin-bottom: 0.5rem;
transition: box-shadow 0.2s ease;
/* Hide button tooltips on touch devices */ &:hover {
@media (pointer: coarse) { box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
[data-tooltip]:before, }
[data-tooltip]:after {
display: none !important;
content: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
}
.clip-segments-container {
margin-top: 1rem;
background-color: white;
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.clip-segments-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground, #333);
margin-bottom: 0.75rem;
}
.segment-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem;
border: 1px solid #e5e7eb;
border-radius: 0.25rem;
margin-bottom: 0.5rem;
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
}
.segment-content {
display: flex;
align-items: center;
}
.segment-thumbnail {
width: 4rem;
height: 2.25rem;
background-size: cover;
background-position: center;
border-radius: 0.25rem;
margin-right: 0.75rem;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3);
}
.segment-info {
display: flex;
flex-direction: column;
}
.segment-title {
font-weight: 500;
font-size: 0.875rem;
color: black;
}
.segment-time {
font-size: 0.75rem;
color: black;
}
.segment-duration {
font-size: 0.75rem;
margin-top: 0.25rem;
display: inline-block;
background-color: #f3f4f6;
padding: 0 0.5rem;
border-radius: 0.25rem;
color: black;
}
.segment-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.delete-button {
padding: 0.375rem;
color: #4b5563;
background-color: #e5e7eb;
border-radius: 9999px;
border: none;
cursor: pointer;
transition:
background-color 0.2s,
color 0.2s;
min-width: auto;
&:hover {
color: black;
background-color: #d1d5db;
} }
svg { .segment-content {
height: 1rem; display: flex;
width: 1rem; align-items: center;
} }
}
.empty-message { .segment-thumbnail {
padding: 1rem; width: 4rem;
text-align: center; height: 2.25rem;
color: rgba(51, 51, 51, 0.7); background-size: cover;
} background-position: center;
border-radius: 0.25rem;
margin-right: 0.75rem;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3);
}
.segment-color-1 { .segment-info {
background-color: rgba(59, 130, 246, 0.15); display: flex;
} flex-direction: column;
.segment-color-2 { }
background-color: rgba(16, 185, 129, 0.15);
} .segment-title {
.segment-color-3 { font-weight: 500;
background-color: rgba(245, 158, 11, 0.15); font-size: 0.875rem;
} color: black;
.segment-color-4 { }
background-color: rgba(239, 68, 68, 0.15);
} .segment-time {
.segment-color-5 { font-size: 0.75rem;
background-color: rgba(139, 92, 246, 0.15); color: black;
} }
.segment-color-6 {
background-color: rgba(236, 72, 153, 0.15); .segment-duration {
} font-size: 0.75rem;
.segment-color-7 { margin-top: 0.25rem;
background-color: rgba(6, 182, 212, 0.15); display: inline-block;
} background-color: #f3f4f6;
.segment-color-8 { padding: 0 0.5rem;
background-color: rgba(250, 204, 21, 0.15); border-radius: 0.25rem;
} color: black;
}
.segment-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.delete-button {
padding: 0.375rem;
color: #4b5563;
background-color: #e5e7eb;
border-radius: 9999px;
border: none;
cursor: pointer;
transition:
background-color 0.2s,
color 0.2s;
min-width: auto;
&:hover {
color: black;
background-color: #d1d5db;
}
svg {
height: 1rem;
width: 1rem;
}
}
.empty-message {
padding: 1rem;
text-align: center;
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);
}
} }

View File

@ -1,397 +1,397 @@
#video-editor-trim-root { #video-editor-trim-root {
/* Tooltip styles - only on desktop where hover is available */ /* Tooltip styles - only on desktop where hover is available */
@media (hover: hover) and (pointer: fine) { @media (hover: hover) and (pointer: fine) {
[data-tooltip] { [data-tooltip] {
position: relative; position: relative;
}
[data-tooltip]:before {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 5px;
background-color: rgba(0, 0, 0, 0.8);
color: white;
text-align: center;
padding: 5px 10px;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s,
visibility 0.2s;
z-index: 1000;
pointer-events: none;
}
[data-tooltip]:after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s,
visibility 0.2s;
pointer-events: none;
}
[data-tooltip]:hover:before,
[data-tooltip]:hover:after {
opacity: 1;
visibility: visible;
}
} }
[data-tooltip]:before { /* Hide button tooltips on touch devices */
content: attr(data-tooltip); @media (pointer: coarse) {
position: absolute; [data-tooltip]:before,
bottom: 100%; [data-tooltip]:after {
left: 50%; display: none !important;
transform: translateX(-50%); content: none !important;
margin-bottom: 5px; opacity: 0 !important;
background-color: rgba(0, 0, 0, 0.8); visibility: hidden !important;
color: white; pointer-events: none !important;
text-align: center; }
padding: 5px 10px;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s,
visibility 0.2s;
z-index: 1000;
pointer-events: none;
} }
[data-tooltip]:after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0;
visibility: hidden;
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,
[data-tooltip]:after {
display: none !important;
content: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
}
.editing-tools-container {
background-color: white;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 2.5rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.flex-container {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
gap: 15px;
width: 100%;
}
.flex-container.single-row {
flex-wrap: nowrap;
}
/* Show full text on larger screens, hide short text */
.full-text {
display: inline;
}
.short-text {
display: none;
}
/* Reset text always visible by default */
.reset-text {
display: inline;
}
.button-group {
display: flex;
align-items: center;
&.play-buttons-group {
gap: 0.75rem;
justify-content: flex-start;
flex: 0 0 auto; /* Don't expand to fill space */
}
&.secondary {
gap: 0.75rem;
align-items: center;
justify-content: flex-end;
margin-left: auto; /* Push to right edge */
}
button {
display: flex;
align-items: center;
color: #333;
background: none;
border: none;
cursor: pointer;
min-width: auto;
&:hover:not(:disabled) {
color: inherit;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
height: 1.25rem;
width: 1.25rem;
margin-right: 0.25rem;
}
}
}
.divider {
border-right: 1px solid #d1d5db;
height: 1.5rem;
margin: 0 0.5rem;
}
/* Style for play buttons with highlight effect */
.play-button,
.preview-button {
font-weight: 600;
display: flex;
align-items: center;
position: relative;
overflow: hidden;
min-width: 80px;
justify-content: center;
font-size: 0.875rem !important;
}
/* Greyed out play button when segments are playing */
.play-button.greyed-out {
opacity: 0.5;
cursor: not-allowed;
}
/* Highlighted stop button with blue pulse on small screens */
.segments-button.highlighted-stop {
background-color: rgba(59, 130, 246, 0.1);
color: #3b82f6;
border: 1px solid #3b82f6;
animation: bluePulse 2s infinite;
}
@keyframes bluePulse {
0% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
}
50% {
box-shadow: 0 0 0 8px rgba(59, 130, 246, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
}
}
/* Completely disable ALL hover effects for play buttons */
.play-button:hover:not(:disabled),
.preview-button:hover:not(:disabled) {
/* Reset everything to prevent any changes */
color: inherit !important;
transform: none !important;
font-size: 0.875rem !important;
width: auto !important;
background: none !important;
}
.play-button svg,
.preview-button svg {
height: 1.5rem;
width: 1.5rem;
/* Make sure SVG scales with the button but doesn't change layout */
flex-shrink: 0;
}
@keyframes pulse {
0% {
opacity: 0.8;
}
50% {
opacity: 1;
}
100% {
opacity: 0.8;
}
}
/* Add responsive button text class */
.button-text {
margin-left: 0.25rem;
}
/* Media queries for the editing tools */
@media (max-width: 992px) {
/* Hide text for undo/redo buttons on medium screens */
.button-group.secondary .button-text {
display: none;
}
}
@media (max-width: 768px) {
/* Keep all buttons in a single row, make them more compact */
.flex-container.single-row {
justify-content: space-between;
}
.button-group {
gap: 0.5rem;
}
/* Keep font size consistent regardless of screen size */
.preview-button,
.play-button {
font-size: 0.875rem !important;
}
}
@media (max-width: 640px) {
/* Prevent container overflow on mobile */
.editing-tools-container { .editing-tools-container {
padding: 0.75rem; background-color: white;
overflow-x: hidden; border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 2.5rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
} }
/* At this breakpoint, make preview button text shorter */ .flex-container {
.preview-button { display: flex;
min-width: auto; justify-content: space-between;
align-items: center;
position: relative;
gap: 15px;
width: 100%;
} }
/* Switch to short text versions */ .flex-container.single-row {
flex-wrap: nowrap;
}
/* Show full text on larger screens, hide short text */
.full-text { .full-text {
display: none; display: inline;
} }
.short-text { .short-text {
display: inline; display: none;
margin-left: 0.15rem;
} }
/* Hide reset text */ /* Reset text always visible by default */
.reset-text { .reset-text {
display: none; display: inline;
} }
/* Ensure buttons stay in correct position */
.button-group.play-buttons-group {
flex: initial;
justify-content: flex-start;
flex-shrink: 0;
}
.button-group.secondary {
flex: initial;
justify-content: flex-end;
flex-shrink: 0;
}
/* Reduce button sizes on mobile */
.button-group button {
padding: 0.375rem;
min-width: auto;
}
.button-group button svg {
height: 1.125rem;
width: 1.125rem;
margin-right: 0.125rem;
}
}
@media (max-width: 576px) {
/* Keep single row, left-align play buttons, right-align controls */
.flex-container.single-row {
justify-content: space-between;
flex-wrap: nowrap;
gap: 10px;
}
/* Fix left-align for play buttons */
.button-group.play-buttons-group {
justify-content: flex-start;
flex: 0 0 auto;
}
/* Fix right-align for editing controls */
.button-group.secondary {
justify-content: flex-end;
margin-left: auto;
}
/* Reduce button padding to fit more easily */
.button-group button {
padding: 0.25rem;
}
.divider {
margin: 0 0.25rem;
}
}
/* Very small screens - maintain layout but reduce further */
@media (max-width: 480px) {
.editing-tools-container {
padding: 0.5rem;
}
.flex-container.single-row {
gap: 8px;
}
.button-group.play-buttons-group,
.button-group.secondary {
gap: 0.25rem;
}
.divider {
display: none; /* Hide divider on very small screens */
}
/* Even smaller buttons on very small screens */
.button-group button {
padding: 0.125rem;
}
.button-group button svg {
height: 1rem;
width: 1rem;
margin-right: 0;
}
/* Hide all button text on very small screens */
.button-text,
.reset-text {
display: none;
}
}
/* Portrait orientation specific fixes */
@media (max-width: 640px) and (orientation: portrait) {
.editing-tools-container {
width: 100%;
box-sizing: border-box;
}
.flex-container.single-row {
width: 100%;
padding: 0;
margin: 0;
}
/* Ensure button groups don't overflow */
.button-group { .button-group {
max-width: 50%; display: flex;
align-items: center;
&.play-buttons-group {
gap: 0.75rem;
justify-content: flex-start;
flex: 0 0 auto; /* Don't expand to fill space */
}
&.secondary {
gap: 0.75rem;
align-items: center;
justify-content: flex-end;
margin-left: auto; /* Push to right edge */
}
button {
display: flex;
align-items: center;
color: #333;
background: none;
border: none;
cursor: pointer;
min-width: auto;
&:hover:not(:disabled) {
color: inherit;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
height: 1.25rem;
width: 1.25rem;
margin-right: 0.25rem;
}
}
} }
.button-group.play-buttons-group { .divider {
max-width: 60%; border-right: 1px solid #d1d5db;
height: 1.5rem;
margin: 0 0.5rem;
} }
.button-group.secondary { /* Style for play buttons with highlight effect */
max-width: 40%; .play-button,
.preview-button {
font-weight: 600;
display: flex;
align-items: center;
position: relative;
overflow: hidden;
min-width: 80px;
justify-content: center;
font-size: 0.875rem !important;
}
/* Greyed out play button when segments are playing */
.play-button.greyed-out {
opacity: 0.5;
cursor: not-allowed;
}
/* Highlighted stop button with blue pulse on small screens */
.segments-button.highlighted-stop {
background-color: rgba(59, 130, 246, 0.1);
color: #3b82f6;
border: 1px solid #3b82f6;
animation: bluePulse 2s infinite;
}
@keyframes bluePulse {
0% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
}
50% {
box-shadow: 0 0 0 8px rgba(59, 130, 246, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
}
}
/* Completely disable ALL hover effects for play buttons */
.play-button:hover:not(:disabled),
.preview-button:hover:not(:disabled) {
/* Reset everything to prevent any changes */
color: inherit !important;
transform: none !important;
font-size: 0.875rem !important;
width: auto !important;
background: none !important;
}
.play-button svg,
.preview-button svg {
height: 1.5rem;
width: 1.5rem;
/* Make sure SVG scales with the button but doesn't change layout */
flex-shrink: 0;
}
@keyframes pulse {
0% {
opacity: 0.8;
}
50% {
opacity: 1;
}
100% {
opacity: 0.8;
}
}
/* Add responsive button text class */
.button-text {
margin-left: 0.25rem;
}
/* Media queries for the editing tools */
@media (max-width: 992px) {
/* Hide text for undo/redo buttons on medium screens */
.button-group.secondary .button-text {
display: none;
}
}
@media (max-width: 768px) {
/* Keep all buttons in a single row, make them more compact */
.flex-container.single-row {
justify-content: space-between;
}
.button-group {
gap: 0.5rem;
}
/* Keep font size consistent regardless of screen size */
.preview-button,
.play-button {
font-size: 0.875rem !important;
}
}
@media (max-width: 640px) {
/* Prevent container overflow on mobile */
.editing-tools-container {
padding: 0.75rem;
overflow-x: hidden;
}
/* At this breakpoint, make preview button text shorter */
.preview-button {
min-width: auto;
}
/* Switch to short text versions */
.full-text {
display: none;
}
.short-text {
display: inline;
margin-left: 0.15rem;
}
/* Hide reset text */
.reset-text {
display: none;
}
/* Ensure buttons stay in correct position */
.button-group.play-buttons-group {
flex: initial;
justify-content: flex-start;
flex-shrink: 0;
}
.button-group.secondary {
flex: initial;
justify-content: flex-end;
flex-shrink: 0;
}
/* Reduce button sizes on mobile */
.button-group button {
padding: 0.375rem;
min-width: auto;
}
.button-group button svg {
height: 1.125rem;
width: 1.125rem;
margin-right: 0.125rem;
}
}
@media (max-width: 576px) {
/* Keep single row, left-align play buttons, right-align controls */
.flex-container.single-row {
justify-content: space-between;
flex-wrap: nowrap;
gap: 10px;
}
/* Fix left-align for play buttons */
.button-group.play-buttons-group {
justify-content: flex-start;
flex: 0 0 auto;
}
/* Fix right-align for editing controls */
.button-group.secondary {
justify-content: flex-end;
margin-left: auto;
}
/* Reduce button padding to fit more easily */
.button-group button {
padding: 0.25rem;
}
.divider {
margin: 0 0.25rem;
}
}
/* Very small screens - maintain layout but reduce further */
@media (max-width: 480px) {
.editing-tools-container {
padding: 0.5rem;
}
.flex-container.single-row {
gap: 8px;
}
.button-group.play-buttons-group,
.button-group.secondary {
gap: 0.25rem;
}
.divider {
display: none; /* Hide divider on very small screens */
}
/* Even smaller buttons on very small screens */
.button-group button {
padding: 0.125rem;
}
.button-group button svg {
height: 1rem;
width: 1rem;
margin-right: 0;
}
/* Hide all button text on very small screens */
.button-text,
.reset-text {
display: none;
}
}
/* Portrait orientation specific fixes */
@media (max-width: 640px) and (orientation: portrait) {
.editing-tools-container {
width: 100%;
box-sizing: border-box;
}
.flex-container.single-row {
width: 100%;
padding: 0;
margin: 0;
}
/* Ensure button groups don't overflow */
.button-group {
max-width: 50%;
}
.button-group.play-buttons-group {
max-width: 60%;
}
.button-group.secondary {
max-width: 40%;
}
} }
}
} }

View File

@ -1,167 +1,167 @@
.ios-notification { .ios-notification {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
z-index: 1000; z-index: 1000;
background-color: #fffdeb; background-color: #fffdeb;
border-bottom: 1px solid #e2e2e2; border-bottom: 1px solid #e2e2e2;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 10px; padding: 10px;
animation: slide-down 0.5s ease-in-out; animation: slide-down 0.5s ease-in-out;
} }
@keyframes slide-down { @keyframes slide-down {
from { from {
transform: translateY(-100%); transform: translateY(-100%);
} }
to { to {
transform: translateY(0); transform: translateY(0);
} }
} }
.ios-notification-content { .ios-notification-content {
max-width: 600px; max-width: 600px;
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
position: relative; position: relative;
padding: 0 10px; padding: 0 10px;
} }
.ios-notification-icon { .ios-notification-icon {
flex-shrink: 0; flex-shrink: 0;
color: #0066cc; color: #0066cc;
margin-right: 15px; margin-right: 15px;
margin-top: 3px; margin-top: 3px;
} }
.ios-notification-message { .ios-notification-message {
flex-grow: 1; flex-grow: 1;
} }
.ios-notification-message h3 { .ios-notification-message h3 {
margin: 0 0 5px 0; margin: 0 0 5px 0;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: #000; color: #000;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
} }
.ios-notification-message p { .ios-notification-message p {
margin: 0 0 8px 0; margin: 0 0 8px 0;
font-size: 14px; font-size: 14px;
color: #333; color: #333;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
} }
.ios-notification-message ol { .ios-notification-message ol {
margin: 0; margin: 0;
padding-left: 20px; padding-left: 20px;
font-size: 14px; font-size: 14px;
color: #333; color: #333;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
} }
.ios-notification-message li { .ios-notification-message li {
margin-bottom: 3px; margin-bottom: 3px;
} }
.ios-notification-close { .ios-notification-close {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
background: none; background: none;
border: none; border: none;
color: #666; color: #666;
cursor: pointer; cursor: pointer;
padding: 5px; padding: 5px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: color 0.2s; transition: color 0.2s;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
.ios-notification-close:hover { .ios-notification-close:hover {
color: #000; color: #000;
} }
/* Desktop mode button styling */ /* Desktop mode button styling */
.ios-mode-options { .ios-mode-options {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
margin-bottom: 8px; margin-bottom: 8px;
} }
.ios-desktop-mode-btn { .ios-desktop-mode-btn {
background-color: #0066cc; background-color: #0066cc;
color: white; color: white;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
padding: 8px 16px; padding: 8px 16px;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin-bottom: 6px; margin-bottom: 6px;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; transition: background-color 0.2s;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
} }
.ios-desktop-mode-btn:hover { .ios-desktop-mode-btn:hover {
background-color: #0055aa; background-color: #0055aa;
} }
.ios-desktop-mode-btn:active { .ios-desktop-mode-btn:active {
background-color: #004499; background-color: #004499;
transform: scale(0.98); transform: scale(0.98);
} }
.ios-or { .ios-or {
font-size: 12px; font-size: 12px;
color: #666; color: #666;
margin: 0 0 6px 0; margin: 0 0 6px 0;
font-style: italic; font-style: italic;
} }
/* iOS-specific styles */ /* iOS-specific styles */
@supports (-webkit-touch-callout: none) { @supports (-webkit-touch-callout: none) {
.ios-notification { .ios-notification {
padding-top: env(safe-area-inset-top); padding-top: env(safe-area-inset-top);
} }
.ios-notification-close { .ios-notification-close {
padding: 10px; padding: 10px;
} }
} }
/* Make sure this notification has better visibility on smaller screens */ /* Make sure this notification has better visibility on smaller screens */
@media (max-width: 480px) { @media (max-width: 480px) {
.ios-notification-content { .ios-notification-content {
padding: 5px; padding: 5px;
} }
.ios-notification-message h3 { .ios-notification-message h3 {
font-size: 15px; font-size: 15px;
} }
.ios-notification-message p, .ios-notification-message p,
.ios-notification-message ol { .ios-notification-message ol {
font-size: 13px; font-size: 13px;
} }
} }
/* Add iOS-specific styles when in desktop mode */ /* Add iOS-specific styles when in desktop mode */
html.ios-device { html.ios-device {
/* Force the content to be rendered at desktop width */ /* Force the content to be rendered at desktop width */
min-width: 1024px; min-width: 1024px;
overflow-x: auto; overflow-x: auto;
} }
html.ios-device .ios-control-btn { html.ios-device .ios-control-btn {
/* Make buttons easier to tap in desktop mode */ /* Make buttons easier to tap in desktop mode */
min-height: 44px; min-height: 44px;
} }

View File

@ -1,96 +1,96 @@
.mobile-play-prompt-overlay { .mobile-play-prompt-overlay {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.7); background-color: rgba(0, 0, 0, 0.7);
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
z-index: 1000; z-index: 1000;
backdrop-filter: blur(5px); backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px);
} }
.mobile-play-prompt { .mobile-play-prompt {
background-color: white; background-color: white;
width: 90%; width: 90%;
max-width: 400px; max-width: 400px;
border-radius: 12px; border-radius: 12px;
padding: 25px; padding: 25px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
text-align: center; text-align: center;
} }
.mobile-play-prompt h3 { .mobile-play-prompt h3 {
margin: 0 0 15px 0; margin: 0 0 15px 0;
font-size: 20px; font-size: 20px;
color: #333; color: #333;
font-weight: 600; font-weight: 600;
} }
.mobile-play-prompt p { .mobile-play-prompt p {
margin: 0 0 15px 0; margin: 0 0 15px 0;
font-size: 16px; font-size: 16px;
color: #444; color: #444;
line-height: 1.5; line-height: 1.5;
} }
.mobile-prompt-instructions { .mobile-prompt-instructions {
margin: 20px 0; margin: 20px 0;
text-align: left; text-align: left;
background-color: #f8f9fa; background-color: #f8f9fa;
padding: 15px; padding: 15px;
border-radius: 8px; border-radius: 8px;
} }
.mobile-prompt-instructions p { .mobile-prompt-instructions p {
margin: 0 0 8px 0; margin: 0 0 8px 0;
font-size: 15px; font-size: 15px;
font-weight: 500; font-weight: 500;
} }
.mobile-prompt-instructions ol { .mobile-prompt-instructions ol {
margin: 0; margin: 0;
padding-left: 22px; padding-left: 22px;
} }
.mobile-prompt-instructions li { .mobile-prompt-instructions li {
margin-bottom: 8px; margin-bottom: 8px;
font-size: 14px; font-size: 14px;
color: #333; color: #333;
} }
.mobile-play-button { .mobile-play-button {
background-color: #007bff; background-color: #007bff;
color: white; color: white;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
padding: 12px 25px; padding: 12px 25px;
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; transition: background-color 0.2s;
margin-top: 5px; margin-top: 5px;
/* Make button easier to tap on mobile */ /* Make button easier to tap on mobile */
min-height: 44px; min-height: 44px;
min-width: 200px; min-width: 200px;
} }
.mobile-play-button:hover { .mobile-play-button:hover {
background-color: #0069d9; background-color: #0069d9;
} }
.mobile-play-button:active { .mobile-play-button:active {
background-color: #0062cc; background-color: #0062cc;
transform: scale(0.98); transform: scale(0.98);
} }
/* Special styles for mobile devices */ /* Special styles for mobile devices */
@supports (-webkit-touch-callout: none) { @supports (-webkit-touch-callout: none) {
.mobile-play-button { .mobile-play-button {
/* Extra spacing for mobile */ /* Extra spacing for mobile */
padding: 14px 25px; padding: 14px 25px;
} }
} }

View File

@ -1,94 +1,94 @@
.ios-video-player-container { .ios-video-player-container {
position: relative; position: relative;
background-color: #f8f8f8; background-color: #f8f8f8;
border: 1px solid #e2e2e2; border: 1px solid #e2e2e2;
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 1rem; padding: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
overflow: hidden; overflow: hidden;
} }
.ios-video-player-container video { .ios-video-player-container video {
width: 100%; width: 100%;
height: auto; height: auto;
max-height: 360px; max-height: 360px;
aspect-ratio: 16/9; aspect-ratio: 16/9;
background-color: black; background-color: black;
} }
.ios-time-display { .ios-time-display {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #333; color: #333;
} }
.ios-note { .ios-note {
text-align: center; text-align: center;
color: #777; color: #777;
font-size: 0.8rem; font-size: 0.8rem;
padding: 0.5rem 0; padding: 0.5rem 0;
} }
/* iOS-specific styling tweaks */ /* iOS-specific styling tweaks */
@supports (-webkit-touch-callout: none) { @supports (-webkit-touch-callout: none) {
.ios-video-player-container video { .ios-video-player-container video {
max-height: 50vh; /* Use viewport height on iOS */ max-height: 50vh; /* Use viewport height on iOS */
} }
/* Improve controls visibility on iOS */ /* Improve controls visibility on iOS */
video::-webkit-media-controls { video::-webkit-media-controls {
opacity: 1 !important; opacity: 1 !important;
visibility: visible !important; visibility: visible !important;
} }
/* Ensure controls don't disappear too quickly */ /* Ensure controls don't disappear too quickly */
video::-webkit-media-controls-panel { video::-webkit-media-controls-panel {
transition-duration: 3s !important; transition-duration: 3s !important;
} }
} }
/* External controls styling */ /* External controls styling */
.ios-external-controls { .ios-external-controls {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
gap: 0.5rem; gap: 0.5rem;
} }
.ios-control-btn { .ios-control-btn {
font-weight: bold; font-weight: bold;
min-width: 100px; min-width: 100px;
height: 44px; /* Minimum touch target size for iOS */ height: 44px; /* Minimum touch target size for iOS */
border: none; border: none;
border-radius: 8px; border-radius: 8px;
transition: all 0.2s ease; transition: all 0.2s ease;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-tap-highlight-color: transparent; /* Remove tap highlight on iOS */ -webkit-tap-highlight-color: transparent; /* Remove tap highlight on iOS */
} }
.ios-control-btn:active { .ios-control-btn:active {
transform: scale(0.98); transform: scale(0.98);
opacity: 0.9; opacity: 0.9;
} }
/* Prevent text selection on buttons */ /* Prevent text selection on buttons */
.no-select { .no-select {
-webkit-touch-callout: none; /* iOS Safari */ -webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Safari */ -webkit-user-select: none; /* Safari */
-khtml-user-select: none; /* Konqueror HTML */ -khtml-user-select: none; /* Konqueror HTML */
-moz-user-select: none; /* Firefox */ -moz-user-select: none; /* Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */ -ms-user-select: none; /* Internet Explorer/Edge */
user-select: none; /* Non-prefixed version, supported by Chrome and Opera */ user-select: none; /* Non-prefixed version, supported by Chrome and Opera */
cursor: default; cursor: default;
} }
/* Specifically prevent default behavior on fine controls */ /* Specifically prevent default behavior on fine controls */
.ios-fine-controls button, .ios-fine-controls button,
.ios-external-controls .no-select { .ios-external-controls .no-select {
touch-action: manipulation; touch-action: manipulation;
-webkit-touch-callout: none; -webkit-touch-callout: none;
-webkit-user-select: none; -webkit-user-select: none;
pointer-events: auto; pointer-events: auto;
} }

View File

@ -1,306 +1,306 @@
#video-editor-trim-root { #video-editor-trim-root {
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(0, 0, 0, 0.5);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000; 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);
} }
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 { .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 { .modal-actions {
flex-direction: column; display: flex;
justify-content: flex-end;
padding: 16px 20px;
border-top: 1px solid #eee;
gap: 12px;
} }
.modal-button { .modal-button {
width: 100%; padding: 8px 16px;
border-radius: 4px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
} }
}
.error-message { .modal-button-primary {
color: #f44336; background-color: #0066cc;
font-weight: 500; color: white;
background-color: rgba(244, 67, 54, 0.1); }
padding: 10px;
border-radius: 4px;
border-left: 4px solid #f44336;
margin-top: 10px;
}
.redirect-message { .modal-button-primary:hover {
margin-top: 20px; background-color: #0055aa;
color: #555; }
font-size: 0.95rem;
padding: 0;
margin: 0;
}
.countdown { .modal-button-secondary {
font-weight: bold; background-color: #f0f0f0;
color: #0066cc; color: #333;
font-size: 1.1rem; }
}
.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;
}
} }

View File

@ -1,70 +1,70 @@
.two-row-tooltip { .two-row-tooltip {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: white; background-color: white;
padding: 6px; padding: 6px;
border-radius: 4px; border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
position: relative; position: relative;
z-index: 3000; /* Highest z-index to ensure it's above all other elements */ z-index: 3000; /* Highest z-index to ensure it's above all other elements */
} }
/* Hide ±100ms buttons for more compact tooltip */ /* Hide ±100ms buttons for more compact tooltip */
.tooltip-time-btn[data-tooltip="Decrease by 100ms"], .tooltip-time-btn[data-tooltip="Decrease by 100ms"],
.tooltip-time-btn[data-tooltip="Increase by 100ms"] { .tooltip-time-btn[data-tooltip="Increase by 100ms"] {
display: none !important; display: none !important;
} }
.tooltip-row { .tooltip-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 3px; gap: 3px;
} }
.tooltip-row:first-child { .tooltip-row:first-child {
margin-bottom: 6px; margin-bottom: 6px;
} }
.tooltip-time-btn { .tooltip-time-btn {
background-color: #f0f0f0 !important; background-color: #f0f0f0 !important;
border: none !important; border: none !important;
border-radius: 4px !important; border-radius: 4px !important;
padding: 4px 8px !important; padding: 4px 8px !important;
font-size: 0.75rem !important; font-size: 0.75rem !important;
font-weight: 500 !important; font-weight: 500 !important;
color: #333 !important; color: #333 !important;
cursor: pointer !important; cursor: pointer !important;
transition: background-color 0.2s !important; transition: background-color 0.2s !important;
min-width: 20px !important; min-width: 20px !important;
} }
.tooltip-time-btn:hover { .tooltip-time-btn:hover {
background-color: #e0e0e0 !important; background-color: #e0e0e0 !important;
} }
.tooltip-time-display { .tooltip-time-display {
font-family: monospace !important; font-family: monospace !important;
font-size: 0.875rem !important; font-size: 0.875rem !important;
font-weight: 600 !important; font-weight: 600 !important;
color: #333 !important; color: #333 !important;
padding: 4px 6px !important; padding: 4px 6px !important;
background-color: #f7f7f7 !important; background-color: #f7f7f7 !important;
border-radius: 4px !important; border-radius: 4px !important;
min-width: 100px !important; min-width: 100px !important;
text-align: center !important; text-align: center !important;
overflow: hidden !important; overflow: hidden !important;
} }
/* Disabled state for time display */ /* Disabled state for time display */
.tooltip-time-display.disabled { .tooltip-time-display.disabled {
pointer-events: none !important; pointer-events: none !important;
cursor: not-allowed !important; cursor: not-allowed !important;
opacity: 0.6 !important; opacity: 0.6 !important;
user-select: none !important; user-select: none !important;
-webkit-user-select: none !important; -webkit-user-select: none !important;
-moz-user-select: none !important; -moz-user-select: none !important;
-ms-user-select: none !important; -ms-user-select: none !important;
} }
/* Force disabled tooltips to show on hover for better user feedback */ /* Force disabled tooltips to show on hover for better user feedback */
@ -72,269 +72,269 @@
.tooltip-time-btn.disabled[data-tooltip]:hover:after, .tooltip-time-btn.disabled[data-tooltip]:hover:after,
.tooltip-action-btn.disabled[data-tooltip]:hover:before, .tooltip-action-btn.disabled[data-tooltip]:hover:before,
.tooltip-action-btn.disabled[data-tooltip]:hover:after { .tooltip-action-btn.disabled[data-tooltip]:hover:after {
opacity: 1 !important; opacity: 1 !important;
visibility: visible !important; visibility: visible !important;
} }
.tooltip-actions { .tooltip-actions {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 3px; gap: 3px;
position: relative; position: relative;
z-index: 2500; /* Higher z-index to ensure buttons appear above other elements */ z-index: 2500; /* Higher z-index to ensure buttons appear above other elements */
} }
.tooltip-action-btn { .tooltip-action-btn {
background-color: #f3f4f6; background-color: #f3f4f6;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
padding: 5px; padding: 5px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
color: #4b5563; color: #4b5563;
width: 26px; width: 26px;
height: 26px; height: 26px;
min-width: 20px !important; min-width: 20px !important;
position: relative; /* Add relative positioning for tooltips */ position: relative; /* Add relative positioning for tooltips */
} }
/* Custom tooltip styles for second row action buttons - positioned below */ /* Custom tooltip styles for second row action buttons - positioned below */
.tooltip-action-btn[data-tooltip]:before { .tooltip-action-btn[data-tooltip]:before {
content: attr(data-tooltip); content: attr(data-tooltip);
position: absolute; position: absolute;
height: 30px; height: 30px;
top: 35px; /* Position below the button with increased space */ top: 35px; /* Position below the button with increased space */
left: 50%; /* Center horizontally */ left: 50%; /* Center horizontally */
transform: translateX(-50%); /* Center horizontally */ transform: translateX(-50%); /* Center horizontally */
margin-left: 0; /* Reset margin */ margin-left: 0; /* Reset margin */
background-color: rgba(0, 0, 0, 0.85); background-color: rgba(0, 0, 0, 0.85);
color: white; color: white;
text-align: left; text-align: left;
padding: 6px 12px; padding: 6px 12px;
border-radius: 4px; border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
font-size: 12px; font-size: 12px;
white-space: nowrap; white-space: nowrap;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: transition:
opacity 0.2s, opacity 0.2s,
visibility 0.2s; visibility 0.2s;
z-index: 2500; /* High z-index */ z-index: 2500; /* High z-index */
pointer-events: none; pointer-events: none;
} }
/* Triangle arrow pointing up to the button */ /* Triangle arrow pointing up to the button */
.tooltip-action-btn[data-tooltip]:after { .tooltip-action-btn[data-tooltip]:after {
content: ""; content: "";
position: absolute; position: absolute;
top: 35px; /* Match the before element */ top: 35px; /* Match the before element */
left: 50%; /* Center horizontally */ left: 50%; /* Center horizontally */
transform: translateX(-50%); /* Center horizontally */ transform: translateX(-50%); /* Center horizontally */
border-width: 4px; border-width: 4px;
border-style: solid; border-style: solid;
/* Arrow pointing down from button to tooltip */ /* Arrow pointing down from button to tooltip */
border-color: rgba(0, 0, 0, 0.85) transparent transparent transparent; border-color: rgba(0, 0, 0, 0.85) transparent transparent transparent;
margin-left: 0; /* Reset margin */ margin-left: 0; /* Reset margin */
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: transition:
opacity 0.2s, opacity 0.2s,
visibility 0.2s; visibility 0.2s;
z-index: 2500; /* High z-index */ z-index: 2500; /* High z-index */
pointer-events: none; pointer-events: none;
} }
/* Show tooltips on hover - but only on devices with hover capability (desktops) */ /* Show tooltips on hover - but only on devices with hover capability (desktops) */
@media (hover: hover) and (pointer: fine) { @media (hover: hover) and (pointer: fine) {
.tooltip-action-btn[data-tooltip]:hover:before, .tooltip-action-btn[data-tooltip]:hover:before,
.tooltip-action-btn[data-tooltip]:hover:after { .tooltip-action-btn[data-tooltip]:hover:after {
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
} }
} }
/* Keep the two-row-tooltip visible but hide button attribute tooltips on touch devices */ /* Keep the two-row-tooltip visible but hide button attribute tooltips on touch devices */
@media (pointer: coarse) { @media (pointer: coarse) {
.tooltip-action-btn[data-tooltip]:before, .tooltip-action-btn[data-tooltip]:before,
.tooltip-action-btn[data-tooltip]:after { .tooltip-action-btn[data-tooltip]:after {
display: none !important; display: none !important;
opacity: 0 !important; opacity: 0 !important;
visibility: hidden !important; visibility: hidden !important;
pointer-events: none !important; pointer-events: none !important;
content: none !important; content: none !important;
} }
} }
.tooltip-action-btn:hover { .tooltip-action-btn:hover {
background-color: #e5e7eb; background-color: #e5e7eb;
color: #111827; color: #111827;
} }
.tooltip-action-btn.delete { .tooltip-action-btn.delete {
color: #ef4444; color: #ef4444;
} }
.tooltip-action-btn.delete:hover { .tooltip-action-btn.delete:hover {
background-color: #fee2e2; background-color: #fee2e2;
} }
.tooltip-action-btn.play { .tooltip-action-btn.play {
color: #10b981; color: #10b981;
} }
.tooltip-action-btn.play:hover { .tooltip-action-btn.play:hover {
background-color: #d1fae5; background-color: #d1fae5;
} }
.tooltip-action-btn.pause { .tooltip-action-btn.pause {
color: #3b82f6; color: #3b82f6;
} }
.tooltip-action-btn.pause:hover { .tooltip-action-btn.pause:hover {
background-color: #dbeafe; background-color: #dbeafe;
} }
.tooltip-action-btn.play-from-start { .tooltip-action-btn.play-from-start {
color: #4f46e5; color: #4f46e5;
} }
.tooltip-action-btn.play-from-start:hover { .tooltip-action-btn.play-from-start:hover {
background-color: #e0e7ff; background-color: #e0e7ff;
} }
.tooltip-action-btn svg { .tooltip-action-btn svg {
width: 16px; width: 16px;
height: 16px; height: 16px;
} }
/* Adjust the new segment button style */ /* Adjust the new segment button style */
.tooltip-action-btn.new-segment { .tooltip-action-btn.new-segment {
width: auto; width: auto;
height: auto; height: auto;
padding: 6px 10px; padding: 6px 10px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
color: #10b981; color: #10b981;
} }
.tooltip-action-btn.new-segment:hover { .tooltip-action-btn.new-segment:hover {
background-color: #d1fae5; background-color: #d1fae5;
} }
.tooltip-action-btn.new-segment .tooltip-btn-text { .tooltip-action-btn.new-segment .tooltip-btn-text {
margin-left: 6px; margin-left: 6px;
font-size: 0.75rem; font-size: 0.75rem;
white-space: nowrap; white-space: nowrap;
} }
/* Disabled state for tooltip action buttons */ /* Disabled state for tooltip action buttons */
.tooltip-action-btn.disabled { .tooltip-action-btn.disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
background-color: #f3f4f6; background-color: #f3f4f6;
} }
.tooltip-action-btn.disabled:hover { .tooltip-action-btn.disabled:hover {
background-color: #f3f4f6; background-color: #f3f4f6;
color: #9ca3af; color: #9ca3af;
} }
.tooltip-action-btn.disabled svg { .tooltip-action-btn.disabled svg {
color: #9ca3af; color: #9ca3af;
} }
.tooltip-action-btn.disabled .tooltip-btn-text { .tooltip-action-btn.disabled .tooltip-btn-text {
color: #9ca3af; color: #9ca3af;
} }
/* Ensure pause button is properly styled when disabled */ /* Ensure pause button is properly styled when disabled */
.tooltip-action-btn.pause.disabled { .tooltip-action-btn.pause.disabled {
color: #9ca3af !important; color: #9ca3af !important;
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.tooltip-action-btn.pause.disabled:hover { .tooltip-action-btn.pause.disabled:hover {
background-color: #f3f4f6 !important; background-color: #f3f4f6 !important;
color: #9ca3af !important; color: #9ca3af !important;
} }
/* Ensure play button is properly styled when disabled */ /* Ensure play button is properly styled when disabled */
.tooltip-action-btn.play.disabled { .tooltip-action-btn.play.disabled {
color: #9ca3af !important; color: #9ca3af !important;
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.tooltip-action-btn.play.disabled:hover { .tooltip-action-btn.play.disabled:hover {
background-color: #f3f4f6 !important; background-color: #f3f4f6 !important;
color: #9ca3af !important; color: #9ca3af !important;
} }
/* Ensure time adjustment buttons are properly styled when disabled */ /* Ensure time adjustment buttons are properly styled when disabled */
.tooltip-time-btn.disabled { .tooltip-time-btn.disabled {
opacity: 0.5 !important; opacity: 0.5 !important;
cursor: not-allowed !important; cursor: not-allowed !important;
background-color: #f3f4f6 !important; background-color: #f3f4f6 !important;
color: #9ca3af !important; color: #9ca3af !important;
} }
.tooltip-time-btn.disabled:hover { .tooltip-time-btn.disabled:hover {
background-color: #f3f4f6 !important; background-color: #f3f4f6 !important;
color: #9ca3af !important; color: #9ca3af !important;
} }
/* Additional mobile optimizations */ /* Additional mobile optimizations */
@media (max-width: 768px) { @media (max-width: 768px) {
.two-row-tooltip { .two-row-tooltip {
padding: 4px; padding: 4px;
} }
.tooltip-row:first-child { .tooltip-row:first-child {
margin-bottom: 4px; margin-bottom: 4px;
} }
.tooltip-time-btn { .tooltip-time-btn {
min-width: 20px !important; min-width: 20px !important;
font-size: 0.7rem !important; font-size: 0.7rem !important;
padding: 3px 6px !important; padding: 3px 6px !important;
} }
.tooltip-time-display { .tooltip-time-display {
font-size: 0.8rem !important; font-size: 0.8rem !important;
padding: 3px 4px !important; padding: 3px 4px !important;
min-width: 90px !important; min-width: 90px !important;
} }
.tooltip-action-btn { .tooltip-action-btn {
width: 24px; width: 24px;
height: 24px; height: 24px;
padding: 4px; padding: 4px;
} }
.tooltip-action-btn.new-segment { .tooltip-action-btn.new-segment {
padding: 4px 8px; padding: 4px 8px;
} }
.tooltip-action-btn svg { .tooltip-action-btn svg {
width: 14px; width: 14px;
height: 14px; height: 14px;
} }
/* Adjust tooltip position for small screens - maintain the same position but adjust size */ /* Adjust tooltip position for small screens - maintain the same position but adjust size */
.tooltip-action-btn[data-tooltip]:before { .tooltip-action-btn[data-tooltip]:before {
min-width: 100px; min-width: 100px;
font-size: 11px; font-size: 11px;
padding: 4px 8px; padding: 4px 8px;
height: 24px; height: 24px;
top: 33px; /* Maintain the same relative distance on mobile */ top: 33px; /* Maintain the same relative distance on mobile */
} }
.tooltip-action-btn[data-tooltip]:after { .tooltip-action-btn[data-tooltip]:after {
top: 33px; /* Match the tooltip position */ top: 33px; /* Match the tooltip position */
} }
} }

View File

@ -1,342 +1,342 @@
#video-editor-trim-root { #video-editor-trim-root {
/* Tooltip styles - only on desktop where hover is available */ /* Tooltip styles - only on desktop where hover is available */
@media (hover: hover) and (pointer: fine) { @media (hover: hover) and (pointer: fine) {
[data-tooltip] { [data-tooltip] {
position: relative; position: relative;
}
[data-tooltip]:before {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 5px;
background-color: rgba(0, 0, 0, 0.8);
color: white;
text-align: center;
padding: 5px 10px;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s,
visibility 0.2s;
z-index: 1000;
pointer-events: none;
}
[data-tooltip]:after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s,
visibility 0.2s;
pointer-events: none;
}
[data-tooltip]:hover:before,
[data-tooltip]:hover:after {
opacity: 1;
visibility: visible;
}
} }
[data-tooltip]:before { /* Hide button tooltips on touch devices */
content: attr(data-tooltip); @media (pointer: coarse) {
position: absolute; [data-tooltip]:before,
bottom: 100%; [data-tooltip]:after {
left: 50%; display: none !important;
transform: translateX(-50%); content: none !important;
margin-bottom: 5px; opacity: 0 !important;
background-color: rgba(0, 0, 0, 0.8); visibility: hidden !important;
color: white; pointer-events: none !important;
text-align: center; }
padding: 5px 10px; }
border-radius: 3px; .video-player-container {
font-size: 12px; position: relative;
white-space: nowrap; width: 100%;
opacity: 0; background: #000;
visibility: hidden; border-radius: 0.5rem;
transition: overflow: hidden;
opacity 0.2s, margin-bottom: 1rem;
visibility 0.2s; aspect-ratio: 16/9;
z-index: 1000; /* Prevent iOS Safari from showing default video controls */
pointer-events: none; -webkit-user-select: none;
user-select: none;
} }
[data-tooltip]:after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0;
visibility: hidden;
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,
[data-tooltip]:after {
display: none !important;
content: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
}
.video-player-container {
position: relative;
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 { .video-player-container video {
/* Additional iOS optimizations */ width: 100%;
-webkit-tap-highlight-color: transparent; height: 100%;
-webkit-touch-callout: none; 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;
} }
}
.play-pause-indicator { /* iOS-specific styles */
position: absolute; @supports (-webkit-touch-callout: none) {
top: 50%; .video-player-container video {
left: 50%; /* Additional iOS optimizations */
transform: translate(-50%, -50%); -webkit-tap-highlight-color: transparent;
width: 60px; -webkit-touch-callout: none;
height: 60px; }
background-color: rgba(0, 0, 0, 0.6);
border-radius: 50%;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.video-player-container:hover .play-pause-indicator {
opacity: 1;
}
.play-pause-indicator::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.play-pause-indicator.play-icon::before {
width: 0;
height: 0;
border-top: 15px solid transparent;
border-bottom: 15px solid transparent;
border-left: 25px solid white;
margin-left: 3px;
}
.play-pause-indicator.pause-icon::before {
width: 20px;
height: 25px;
border-left: 6px solid white;
border-right: 6px solid white;
}
/* iOS First-play indicator */
.ios-first-play-indicator {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.ios-play-message {
color: white;
font-size: 1.2rem;
text-align: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.8);
border-radius: 0.5rem;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
opacity: 0.7;
transform: scale(1);
} }
50% {
opacity: 1; .play-pause-indicator {
transform: scale(1.05); position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 60px;
height: 60px;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 50%;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
} }
100% {
opacity: 0.7; .video-player-container:hover .play-pause-indicator {
transform: scale(1); opacity: 1;
} }
}
.video-controls { .play-pause-indicator::before {
position: absolute; content: "";
bottom: 0; position: absolute;
left: 0; top: 50%;
right: 0; left: 50%;
padding: 0.75rem; transform: translate(-50%, -50%);
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); }
opacity: 0;
transition: opacity 0.3s;
}
.video-player-container:hover .video-controls { .play-pause-indicator.play-icon::before {
opacity: 1; width: 0;
} height: 0;
border-top: 15px solid transparent;
border-bottom: 15px solid transparent;
border-left: 25px solid white;
margin-left: 3px;
}
.video-current-time { .play-pause-indicator.pause-icon::before {
color: white; width: 20px;
font-size: 0.875rem; height: 25px;
} border-left: 6px solid white;
border-right: 6px solid white;
}
.video-duration { /* iOS First-play indicator */
color: white; .ios-first-play-indicator {
font-size: 0.875rem; 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-time-display { .ios-play-message {
display: flex; color: white;
justify-content: space-between; font-size: 1.2rem;
margin-bottom: 0.5rem; text-align: center;
color: white; padding: 1rem;
font-size: 0.875rem; background: rgba(0, 0, 0, 0.8);
} border-radius: 0.5rem;
animation: pulse 2s infinite;
}
.video-progress { @keyframes pulse {
position: relative; 0% {
height: 6px; opacity: 0.7;
background-color: rgba(255, 255, 255, 0.3); transform: scale(1);
border-radius: 3px; }
cursor: pointer; 50% {
margin: 0 10px; opacity: 1;
touch-action: none; /* Prevent browser handling of drag gestures */ transform: scale(1.05);
flex-grow: 1; }
} 100% {
opacity: 0.7;
transform: scale(1);
}
}
.video-progress.dragging { .video-controls {
height: 8px; position: absolute;
} bottom: 0;
left: 0;
right: 0;
padding: 0.75rem;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
opacity: 0;
transition: opacity 0.3s;
}
.video-progress-fill { .video-player-container:hover .video-controls {
position: absolute; opacity: 1;
top: 0; }
left: 0;
height: 100%;
background-color: #ff0000;
border-radius: 3px;
pointer-events: none;
}
.video-scrubber { .video-current-time {
position: absolute; color: white;
top: 50%; font-size: 0.875rem;
transform: translate(-50%, -50%); }
width: 16px;
height: 16px;
background-color: #ff0000;
border-radius: 50%;
cursor: grab;
transition:
transform 0.1s ease,
width 0.1s ease,
height 0.1s ease;
}
/* Make the scrubber larger when dragging for better control */ .video-duration {
.video-progress.dragging .video-scrubber { color: white;
transform: translate(-50%, -50%) scale(1.2); font-size: 0.875rem;
width: 18px; }
height: 18px;
cursor: grabbing; .video-time-display {
box-shadow: 0 0 8px rgba(255, 0, 0, 0.6); display: flex;
} justify-content: space-between;
margin-bottom: 0.5rem;
color: white;
font-size: 0.875rem;
}
.video-progress {
position: relative;
height: 6px;
background-color: rgba(255, 255, 255, 0.3);
border-radius: 3px;
cursor: pointer;
margin: 0 10px;
touch-action: none; /* Prevent browser handling of drag gestures */
flex-grow: 1;
}
.video-progress.dragging {
height: 8px;
}
.video-progress-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background-color: #ff0000;
border-radius: 3px;
pointer-events: none;
}
/* Enhance for touch devices */
@media (pointer: coarse) {
.video-scrubber { .video-scrubber {
width: 20px; position: absolute;
height: 20px; top: 50%;
transform: translate(-50%, -50%);
width: 16px;
height: 16px;
background-color: #ff0000;
border-radius: 50%;
cursor: grab;
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 { .video-progress.dragging .video-scrubber {
width: 24px; transform: translate(-50%, -50%) scale(1.2);
height: 24px; width: 18px;
height: 18px;
cursor: grabbing;
box-shadow: 0 0 8px rgba(255, 0, 0, 0.6);
} }
/* Create a larger invisible touch target */ /* Enhance for touch devices */
.video-scrubber:before { @media (pointer: coarse) {
content: ""; .video-scrubber {
position: absolute; width: 20px;
top: -10px; height: 20px;
left: -10px; }
right: -10px;
bottom: -10px;
}
}
.video-controls-buttons { .video-progress.dragging .video-scrubber {
display: flex; width: 24px;
align-items: center; height: 24px;
justify-content: flex-end; }
gap: 0.75rem;
}
.mute-button, /* Create a larger invisible touch target */
.fullscreen-button { .video-scrubber:before {
min-width: auto; content: "";
color: white; position: absolute;
background: none; top: -10px;
border: none; left: -10px;
cursor: pointer; right: -10px;
padding: 0.25rem; bottom: -10px;
transition: transform 0.2s; }
&:hover {
transform: scale(1.1);
} }
svg { .video-controls-buttons {
width: 1.25rem; display: flex;
height: 1.25rem; align-items: center;
justify-content: flex-end;
gap: 0.75rem;
} }
}
/* Time tooltip that appears when dragging */ .mute-button,
.video-time-tooltip { .fullscreen-button {
position: absolute; min-width: auto;
top: -30px; color: white;
background-color: rgba(0, 0, 0, 0.7); background: none;
color: white; border: none;
padding: 4px 8px; cursor: pointer;
border-radius: 4px; padding: 0.25rem;
font-size: 12px; transition: transform 0.2s;
font-family: monospace;
pointer-events: none;
z-index: 1000;
white-space: nowrap;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
/* Add a small arrow to the tooltip */ &:hover {
.video-time-tooltip:after { transform: scale(1.1);
content: ""; }
position: absolute;
bottom: -4px; svg {
left: 50%; width: 1.25rem;
transform: translateX(-50%); height: 1.25rem;
width: 0; }
height: 0; }
border-left: 4px solid transparent;
border-right: 4px solid transparent; /* Time tooltip that appears when dragging */
border-top: 4px solid rgba(0, 0, 0, 0.7); .video-time-tooltip {
} position: absolute;
top: -30px;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-family: monospace;
pointer-events: none;
z-index: 1000;
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: "";
position: absolute;
bottom: -4px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid rgba(0, 0, 0, 0.7);
}
} }

View File

@ -3788,9 +3788,9 @@
} }
}, },
"node_modules/aproba": { "node_modules/aproba": {
"version": "2.0.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
"integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
"license": "ISC", "license": "ISC",
"optional": true "optional": true
}, },
@ -12590,9 +12590,9 @@
} }
}, },
"node_modules/nan": { "node_modules/nan": {
"version": "2.22.2", "version": "2.23.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz",
"integrity": "sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==", "integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },

File diff suppressed because it is too large Load Diff