2025-06-24 17:07:28 +03:00

453 lines
15 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-10m.mp4";
// Detect iOS device
useEffect(() => {
const checkIOS = () => {
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
};
setIsIOS(checkIOS());
// Check if video was previously initialized
if (typeof window !== "undefined") {
const wasInitialized = localStorage.getItem("video_initialized") === "true";
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="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)}
</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;