mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-11-07 16:08:54 -05:00
Adds Safari-specific detection and initialization logic to better support audio and video playback, especially for cases where metadata is not loaded as expected. Implements fallback event listeners, user interaction triggers, and exposes an initialization helper to ensure the editor works reliably on Safari and iOS devices.
483 lines
18 KiB
TypeScript
483 lines
18 KiB
TypeScript
import React, { useRef, useEffect, useState } from 'react';
|
|
import { formatTime, formatDetailedTime } from '@/lib/timeUtils';
|
|
import logger from '../lib/logger';
|
|
import '../styles/VideoPlayer.css';
|
|
|
|
interface VideoPlayerProps {
|
|
videoRef: React.RefObject<HTMLVideoElement>;
|
|
currentTime: number;
|
|
duration: number;
|
|
isPlaying: boolean;
|
|
isMuted?: boolean;
|
|
onPlayPause: () => void;
|
|
onSeek: (time: number) => void;
|
|
onToggleMute?: () => void;
|
|
}
|
|
|
|
const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|
videoRef,
|
|
currentTime,
|
|
duration,
|
|
isPlaying,
|
|
isMuted = false,
|
|
onPlayPause,
|
|
onSeek,
|
|
onToggleMute,
|
|
}) => {
|
|
const progressRef = useRef<HTMLDivElement>(null);
|
|
const [isIOS, setIsIOS] = useState(false);
|
|
const [hasInitialized, setHasInitialized] = useState(false);
|
|
const [lastPosition, setLastPosition] = useState<number | null>(null);
|
|
const [isDraggingProgress, setIsDraggingProgress] = useState(false);
|
|
const isDraggingProgressRef = useRef(false);
|
|
const [tooltipPosition, setTooltipPosition] = useState({
|
|
x: 0,
|
|
});
|
|
const [tooltipTime, setTooltipTime] = useState(0);
|
|
|
|
const sampleVideoUrl =
|
|
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.videoUrl) || '/videos/sample-video.mp4';
|
|
|
|
// Detect iOS device and Safari browser
|
|
useEffect(() => {
|
|
const checkIOS = () => {
|
|
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
|
|
return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
|
|
};
|
|
|
|
const checkSafari = () => {
|
|
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
|
|
return /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent);
|
|
};
|
|
|
|
setIsIOS(checkIOS());
|
|
|
|
// Store Safari detection globally for other components
|
|
if (typeof window !== 'undefined') {
|
|
(window as any).isSafari = checkSafari();
|
|
}
|
|
|
|
// Check if video was previously initialized
|
|
if (typeof window !== 'undefined') {
|
|
const wasInitialized = localStorage.getItem('video_initialized') === 'true';
|
|
setHasInitialized(wasInitialized);
|
|
}
|
|
}, []);
|
|
|
|
// Update initialized state when video plays
|
|
useEffect(() => {
|
|
if (isPlaying && !hasInitialized) {
|
|
setHasInitialized(true);
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem('video_initialized', 'true');
|
|
}
|
|
}
|
|
}, [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);
|
|
};
|
|
|
|
// Jump 10 seconds backward
|
|
const handleBackward = () => {
|
|
const newTime = Math.max(currentTime - 10, 0);
|
|
onSeek(newTime);
|
|
setLastPosition(newTime);
|
|
};
|
|
|
|
// Calculate progress percentage
|
|
const progressPercentage = duration > 0 ? (currentTime / duration) * 100 : 0;
|
|
|
|
// Handle start of progress bar dragging
|
|
const handleProgressDragStart = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
|
|
setIsDraggingProgress(true);
|
|
isDraggingProgressRef.current = true;
|
|
|
|
// Get initial position
|
|
handleProgressDrag(e);
|
|
|
|
// Set up document-level event listeners for mouse movement and release
|
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
|
if (isDraggingProgressRef.current) {
|
|
handleProgressDrag(moveEvent);
|
|
}
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setIsDraggingProgress(false);
|
|
isDraggingProgressRef.current = false;
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
|
document.removeEventListener('mouseup', handleMouseUp);
|
|
};
|
|
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
document.addEventListener('mouseup', handleMouseUp);
|
|
};
|
|
|
|
// Handle progress dragging for both mouse and touch events
|
|
const handleProgressDrag = (e: MouseEvent | React.MouseEvent) => {
|
|
if (!progressRef.current) return;
|
|
|
|
const rect = progressRef.current.getBoundingClientRect();
|
|
const clickPosition = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
const seekTime = duration * clickPosition;
|
|
|
|
// Update tooltip position and time
|
|
setTooltipPosition({
|
|
x: e.clientX,
|
|
});
|
|
setTooltipTime(seekTime);
|
|
|
|
// Store position locally for iOS Safari - critical for timeline seeking
|
|
setLastPosition(seekTime);
|
|
|
|
// Also store globally for integration with other components
|
|
if (typeof window !== 'undefined') {
|
|
(window as any).lastSeekedPosition = seekTime;
|
|
}
|
|
|
|
onSeek(seekTime);
|
|
};
|
|
|
|
// Handle touch events for progress bar
|
|
const handleProgressTouchStart = (e: React.TouchEvent) => {
|
|
if (!progressRef.current || !e.touches[0]) return;
|
|
e.preventDefault();
|
|
|
|
setIsDraggingProgress(true);
|
|
isDraggingProgressRef.current = true;
|
|
|
|
// Get initial position using touch
|
|
handleProgressTouchMove(e);
|
|
|
|
// Set up document-level event listeners for touch movement and release
|
|
const handleTouchMove = (moveEvent: TouchEvent) => {
|
|
if (isDraggingProgressRef.current) {
|
|
handleProgressTouchMove(moveEvent);
|
|
}
|
|
};
|
|
|
|
const handleTouchEnd = () => {
|
|
setIsDraggingProgress(false);
|
|
isDraggingProgressRef.current = false;
|
|
document.removeEventListener('touchmove', handleTouchMove);
|
|
document.removeEventListener('touchend', handleTouchEnd);
|
|
document.removeEventListener('touchcancel', handleTouchEnd);
|
|
};
|
|
|
|
document.addEventListener('touchmove', handleTouchMove, {
|
|
passive: false,
|
|
});
|
|
document.addEventListener('touchend', handleTouchEnd);
|
|
document.addEventListener('touchcancel', handleTouchEnd);
|
|
};
|
|
|
|
// Handle touch dragging on progress bar
|
|
const handleProgressTouchMove = (e: TouchEvent | React.TouchEvent) => {
|
|
if (!progressRef.current) return;
|
|
|
|
// Get the touch coordinates
|
|
const touch = 'touches' in e ? e.touches[0] : null;
|
|
if (!touch) return;
|
|
|
|
e.preventDefault(); // Prevent scrolling while dragging
|
|
|
|
const rect = progressRef.current.getBoundingClientRect();
|
|
const touchPosition = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
|
|
const seekTime = duration * touchPosition;
|
|
|
|
// Update tooltip position and time
|
|
setTooltipPosition({
|
|
x: touch.clientX,
|
|
});
|
|
setTooltipTime(seekTime);
|
|
|
|
// Store position for iOS Safari
|
|
setLastPosition(seekTime);
|
|
|
|
// Also store globally for integration with other components
|
|
if (typeof window !== 'undefined') {
|
|
(window as any).lastSeekedPosition = seekTime;
|
|
}
|
|
|
|
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="metadata"
|
|
crossOrigin="anonymous"
|
|
onClick={handleVideoClick}
|
|
playsInline
|
|
webkit-playsinline="true"
|
|
x-webkit-airplay="allow"
|
|
controls={false}
|
|
muted={isMuted}
|
|
>
|
|
<source src={sampleVideoUrl} type="video/mp4" />
|
|
{/* Safari fallback for audio files */}
|
|
<source src={sampleVideoUrl} type="audio/mp4" />
|
|
<source src={sampleVideoUrl} type="audio/mpeg" />
|
|
<p>Your browser doesn't support HTML5 video or audio.</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>
|
|
);
|
|
};
|
|
|
|
export default VideoPlayer;
|