import React, { useRef, useState, useEffect } from 'react'; import { Download, UserPlus, Check, Volume2, VolumeX, AlertCircle } from 'lucide-react'; import type { Video } from '../types'; import { API_BASE_URL } from '../config'; import { videoCache } from '../utils/videoCache'; // Check if browser supports HEVC codec (Safari, Chrome 107+, Edge) const supportsHEVC = (): boolean => { if (typeof MediaSource === 'undefined') return false; return MediaSource.isTypeSupported('video/mp4; codecs="hvc1"') || MediaSource.isTypeSupported('video/mp4; codecs="hev1"'); }; interface HeartParticle { id: number; x: number; y: number; } interface VideoPlayerProps { video: Video; isActive: boolean; isFollowing?: boolean; onFollow?: (author: string) => void; onAuthorClick?: (author: string) => void; // In-app navigation to creator isMuted?: boolean; // Global mute state from parent onMuteToggle?: () => void; // Callback to toggle parent mute state } export const VideoPlayer: React.FC = ({ video, isActive, isFollowing = false, onFollow, onAuthorClick, isMuted: externalMuted, onMuteToggle }) => { const videoRef = useRef(null); const containerRef = useRef(null); const progressBarRef = useRef(null); const [isPaused, setIsPaused] = useState(false); const [showControls, setShowControls] = useState(false); const [objectFit, setObjectFit] = useState<'cover' | 'contain'>('contain'); const [progress, setProgress] = useState(0); const [duration, setDuration] = useState(0); const [isSeeking, setIsSeeking] = useState(false); const [useFallback, setUseFallback] = useState(false); const [localMuted, setLocalMuted] = useState(true); const isMuted = externalMuted !== undefined ? externalMuted : localMuted; const [hearts, setHearts] = useState([]); const [isLoading, setIsLoading] = useState(true); // Show loading spinner until video is ready const [cachedUrl, setCachedUrl] = useState(null); const [codecError, setCodecError] = useState(false); // True if video codec not supported const lastTapRef = useRef(0); const browserSupportsHEVC = useRef(supportsHEVC()); const fullProxyUrl = `${API_BASE_URL}/feed/proxy?url=${encodeURIComponent(video.url)}`; const thinProxyUrl = video.cdn_url ? `${API_BASE_URL}/feed/thin-proxy?cdn_url=${encodeURIComponent(video.cdn_url)}` : null; const proxyUrl = cachedUrl ? cachedUrl : (thinProxyUrl && !useFallback) ? thinProxyUrl : fullProxyUrl; const downloadUrl = `${API_BASE_URL}/feed/proxy?url=${encodeURIComponent(video.url)}&download=true`; useEffect(() => { if (isActive && videoRef.current) { // Auto-play when becoming active if (videoRef.current.paused) { videoRef.current.currentTime = 0; videoRef.current.muted = isMuted; // Use current mute state videoRef.current.play().catch((err) => { // If autoplay fails even muted, show paused state console.log('Autoplay blocked:', err.message); setIsPaused(true); }); setIsPaused(false); } } else if (!isActive && videoRef.current) { videoRef.current.pause(); } }, [isActive]); // Only trigger on isActive change // Sync video muted property when isMuted state changes useEffect(() => { if (videoRef.current) { videoRef.current.muted = isMuted; } }, [isMuted]); // Spacebar to pause/play when this video is active useEffect(() => { if (!isActive) return; const handleKeyDown = (e: KeyboardEvent) => { // Only handle spacebar when not typing const target = e.target as HTMLElement; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return; if (e.code === 'Space') { e.preventDefault(); if (videoRef.current) { if (videoRef.current.paused) { videoRef.current.play(); setIsPaused(false); } else { videoRef.current.pause(); setIsPaused(true); } } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [isActive]); // Reset fallback and loading state when video changes useEffect(() => { setUseFallback(false); setIsLoading(true); // Show loading for new video setCodecError(false); // Reset codec error for new video setCachedUrl(null); const checkCache = async () => { const cached = await videoCache.get(video.url); if (cached) { const blob_url = URL.createObjectURL(cached); setCachedUrl(blob_url); } }; checkCache(); }, [video.id]); // Progress tracking useEffect(() => { const video = videoRef.current; if (!video) return; const handleTimeUpdate = () => { setProgress(video.currentTime); }; const handleLoadedMetadata = () => { setDuration(video.duration); }; // Fallback on error - if thin proxy fails, switch to full proxy // Also detect codec errors for graceful fallback UI const handleError = (e: Event) => { const videoEl = e.target as HTMLVideoElement; const error = videoEl?.error; // Check if this is a codec/decode error (MEDIA_ERR_DECODE = 3) if (error?.code === 3 || error?.code === 4) { console.log(`Codec error detected (code ${error.code}):`, error.message); // Only show codec error if browser doesn't support HEVC if (!browserSupportsHEVC.current) { setCodecError(true); setIsLoading(false); return; } } if (thinProxyUrl && !useFallback) { console.log('Thin proxy failed, falling back to full proxy...'); setUseFallback(true); } }; video.addEventListener('timeupdate', handleTimeUpdate); video.addEventListener('loadedmetadata', handleLoadedMetadata); video.addEventListener('error', handleError); return () => { video.removeEventListener('timeupdate', handleTimeUpdate); video.removeEventListener('loadedmetadata', handleLoadedMetadata); video.removeEventListener('error', handleError); }; }, [thinProxyUrl, useFallback, cachedUrl]); useEffect(() => { const cacheVideo = async () => { if (!cachedUrl || !proxyUrl || proxyUrl === cachedUrl) return; try { const response = await fetch(proxyUrl); if (response.ok) { const blob = await response.blob(); await videoCache.set(video.url, blob); } } catch (error) { console.debug('Failed to cache video:', error); } }; if (isActive && !isLoading) { cacheVideo(); } }, [isActive, isLoading, proxyUrl, cachedUrl, video.url]); const togglePlayPause = () => { if (!videoRef.current) return; if (videoRef.current.paused) { videoRef.current.play(); setIsPaused(false); } else { videoRef.current.pause(); setIsPaused(true); } }; const toggleObjectFit = () => { setObjectFit(prev => prev === 'contain' ? 'cover' : 'contain'); }; const toggleMute = (e: React.MouseEvent | React.TouchEvent) => { e.stopPropagation(); // Prevent video tap if (!videoRef.current) return; // Use external toggle if provided, otherwise use local state if (onMuteToggle) { onMuteToggle(); } else { setLocalMuted(prev => !prev); } }; // Handle tap - double tap shows heart, single tap toggles play const tapTimeoutRef = useRef | null>(null); // Touch handler for multi-touch support const handleTouchStart = (e: React.TouchEvent) => { setShowControls(true); // Ensure controls show on touch const now = Date.now(); const touches = Array.from(e.changedTouches); // Use container rect for stable coordinates vs e.target if (!containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); const isMultiTouch = e.touches.length > 1; // Any simultaneous touches = hearts // Check if this is a rapid tap sequence (potential hearts) let isRapid = false; touches.forEach((touch, index) => { const timeSinceLastTap = now - lastTapRef.current; // Show heart if: // 1. Double tap (< 400ms) // 2. OR Multi-touch (2+ fingers) // 3. OR Secondary touch in this event if (timeSinceLastTap < 400 || isMultiTouch || index > 0) { isRapid = true; const x = touch.clientX - rect.left; const y = touch.clientY - rect.top; // Add heart const heartId = Date.now() + index + Math.random(); // Unique ID setHearts(prev => [...prev, { id: heartId, x, y }]); setTimeout(() => { setHearts(prev => prev.filter(h => h.id !== heartId)); }, 1000); } }); if (isRapid) { // It was a heart tap - prevent default click (toggle pause) if (tapTimeoutRef.current) { clearTimeout(tapTimeoutRef.current); tapTimeoutRef.current = null; } } lastTapRef.current = now; }; // Click handler (Mouse / Single Touch fallback) const handleVideoClick = (e: React.MouseEvent) => { // If we recently parsed a rapid touch (heart), ignore this click const now = Date.now(); if (now - lastTapRef.current < 100) return; // Check for double-click (for hearts) if (tapTimeoutRef.current) { // Double click detected - show heart instead of toggle clearTimeout(tapTimeoutRef.current); tapTimeoutRef.current = null; // Add heart at click position if (containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const heartId = Date.now() + Math.random(); setHearts(prev => [...prev, { id: heartId, x, y }]); setTimeout(() => { setHearts(prev => prev.filter(h => h.id !== heartId)); }, 1000); } } else { // First click - set timeout for double-click detection tapTimeoutRef.current = setTimeout(() => { togglePlayPause(); tapTimeoutRef.current = null; }, 250); } lastTapRef.current = now; }; const handleSeek = (e: React.MouseEvent | React.TouchEvent) => { if (!videoRef.current || !duration || !progressBarRef.current) return; const rect = progressBarRef.current.getBoundingClientRect(); let clientX: number; if ('touches' in e) { clientX = e.touches[0].clientX; } else { clientX = e.clientX; } const clickX = Math.max(0, Math.min(clientX - rect.left, rect.width)); const percent = clickX / rect.width; videoRef.current.currentTime = percent * duration; setProgress(percent * duration); }; const handleSeekStart = (e: React.MouseEvent | React.TouchEvent) => { setIsSeeking(true); handleSeek(e); }; const handleSeekEnd = () => { setIsSeeking(false); }; const formatTime = (time: number) => { const mins = Math.floor(time / 60); const secs = Math.floor(time % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; }; return (
setShowControls(true)} onMouseLeave={() => setShowControls(false)} onClick={handleVideoClick} onTouchStart={handleTouchStart} > {/* Video Element - preload="metadata" for instant player readiness */}