import React, { useRef, useEffect, useState } from 'react'; import { formatTime, formatDetailedTime } from '@/lib/timeUtils'; import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl'; import logger from '../lib/logger'; import '../styles/VideoPlayer.css'; interface VideoPlayerProps { videoRef: React.RefObject; currentTime: number; duration: number; isPlaying: boolean; isMuted?: boolean; onPlayPause: () => void; onSeek: (time: number) => void; onToggleMute?: () => void; } const VideoPlayer: React.FC = ({ videoRef, currentTime, duration, isPlaying, isMuted = false, onPlayPause, onSeek, onToggleMute, }) => { const progressRef = useRef(null); const [isIOS, setIsIOS] = useState(false); const [hasInitialized, setHasInitialized] = useState(false); const [lastPosition, setLastPosition] = useState(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.mp3'; // Check if the media is an audio file const isAudioFile = sampleVideoUrl.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null; // Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None" const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || ''; const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== ''; const posterImage = isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined); // 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) => { // 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 (
{/* iOS First-play indicator - only shown on first visit for iOS devices when not initialized */} {isIOS && !hasInitialized && !isPlaying && (
Tap Play to initialize video controls
)} {/* Play/Pause Indicator (shows based on current state) */}
{/* Video Controls Overlay */}
{/* Time and Duration */}
{formatTime(currentTime)} / {formatTime(duration)}
{/* Progress Bar with enhanced dragging */}
{/* Floating time tooltip when dragging */} {isDraggingProgress && (
{formatDetailedTime(tooltipTime)}
)}
{/* Controls - Mute and Fullscreen buttons */}
{/* Mute/Unmute Button */} {onToggleMute && ( )} {/* Fullscreen Button */}
); }; export default VideoPlayer;