purestream/frontend/src/components/VideoPlayer.tsx

549 lines
22 KiB
TypeScript

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<VideoPlayerProps> = ({
video,
isActive,
isFollowing = false,
onFollow,
onAuthorClick,
isMuted: externalMuted,
onMuteToggle
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const progressBarRef = useRef<HTMLDivElement>(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<HeartParticle[]>([]);
const [isLoading, setIsLoading] = useState(true); // Show loading spinner until video is ready
const [cachedUrl, setCachedUrl] = useState<string | null>(null);
const [codecError, setCodecError] = useState(false); // True if video codec not supported
const lastTapRef = useRef<number>(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<ReturnType<typeof setTimeout> | null>(null);
// Touch handler for multi-touch support
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
// 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<HTMLDivElement> | React.TouchEvent<HTMLDivElement>) => {
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<HTMLDivElement> | React.TouchEvent<HTMLDivElement>) => {
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 (
<div
ref={containerRef}
className="relative w-full h-full bg-black flex items-center justify-center overflow-hidden"
onMouseEnter={() => setShowControls(true)}
onMouseLeave={() => setShowControls(false)}
onClick={handleVideoClick}
onTouchStart={handleTouchStart}
>
{/* Video Element - preload="metadata" for instant player readiness */}
<video
ref={videoRef}
src={proxyUrl}
loop
playsInline
preload="metadata"
muted={isMuted}
className="w-full h-full"
style={{ objectFit }}
onCanPlay={() => setIsLoading(false)}
onWaiting={() => setIsLoading(true)}
onPlaying={() => setIsLoading(false)}
/>
{/* Loading Overlay - Subtle pulsing logo */}
{isLoading && !codecError && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 z-20">
<div className="w-16 h-16 bg-gradient-to-r from-cyan-400/80 to-pink-500/80 rounded-2xl flex items-center justify-center animate-pulse">
<svg className="w-8 h-8 text-white" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
</svg>
</div>
</div>
)}
{/* Codec Error Fallback - Graceful UI for unsupported video codecs */}
{codecError && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80 z-20 p-6 text-center">
<AlertCircle className="w-12 h-12 text-amber-400 mb-3" />
<h3 className="text-white font-semibold text-lg mb-2">Video Format Not Supported</h3>
<p className="text-white/60 text-sm mb-4 max-w-xs">
This video uses HEVC codec. Try Safari, Chrome 107+, or download to watch.
</p>
<a
href={downloadUrl}
download
className="px-4 py-2 bg-gradient-to-r from-cyan-500 to-pink-500 text-white text-sm font-medium rounded-full hover:opacity-90 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
Download Video
</a>
</div>
)}
{/* Heart Animation Particles */}
{hearts.map(heart => (
<div
key={heart.id}
className="absolute z-50 pointer-events-none animate-heart-float"
style={{
left: heart.x - 24,
top: heart.y - 24,
}}
>
<svg className="w-16 h-16 text-pink-500 drop-shadow-xl filter drop-shadow-lg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
</div>
))}
{/* Pause Icon Overlay */}
{isPaused && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20 pointer-events-none">
<div className="w-20 h-20 flex items-center justify-center bg-white/20 backdrop-blur-sm rounded-full">
<svg className="w-10 h-10 text-white ml-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
)}
{/* Video Timeline/Progress Bar */}
<div className="absolute bottom-0 left-0 right-0 z-30">
<div
ref={progressBarRef}
className={`h-2 bg-white/20 cursor-pointer group ${isSeeking ? 'h-3' : ''}`}
onClick={handleSeek}
onMouseDown={handleSeekStart}
onMouseMove={(e) => isSeeking && handleSeek(e)}
onMouseUp={handleSeekEnd}
onMouseLeave={handleSeekEnd}
onTouchStart={handleSeekStart}
onTouchMove={(e) => isSeeking && handleSeek(e)}
onTouchEnd={handleSeekEnd}
>
<div
className="h-full bg-gradient-to-r from-cyan-400 to-pink-500 transition-all pointer-events-none"
style={{ width: duration ? `${(progress / duration) * 100}%` : '0%' }}
/>
{/* Scrubber Thumb (always visible when seeking or on hover) */}
<div
className={`absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-white rounded-full shadow-lg transition-opacity pointer-events-none ${isSeeking ? 'opacity-100 scale-110' : 'opacity-0 group-hover:opacity-100'
}`}
style={{ left: duration ? `calc(${(progress / duration) * 100}% - 8px)` : '0' }}
/>
</div>
{/* Time Display */}
{showControls && duration > 0 && (
<div className="flex justify-between px-4 py-1 text-xs text-white/60">
<span>{formatTime(progress)}</span>
<span>{formatTime(duration)}</span>
</div>
)}
</div>
{/* Side Controls */}
<div
className={`absolute bottom-36 right-4 flex flex-col gap-3 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'
}`}
>
{/* Follow Button */}
{onFollow && (
<button
onClick={() => onFollow(video.author)}
className={`w-12 h-12 flex items-center justify-center backdrop-blur-xl border border-white/10 rounded-full transition-all ${isFollowing
? 'bg-pink-500 text-white'
: 'bg-white/10 hover:bg-white/20 text-white'
}`}
title={isFollowing ? 'Following' : 'Follow'}
>
{isFollowing ? <Check size={20} /> : <UserPlus size={20} />}
</button>
)}
{/* Download Button */}
<a
href={downloadUrl}
download
className="w-12 h-12 flex items-center justify-center bg-white/10 hover:bg-white/20 backdrop-blur-xl border border-white/10 rounded-full text-white transition-all"
title="Download"
>
<Download size={20} />
</a>
{/* Object Fit Toggle */}
<button
onClick={toggleObjectFit}
className="w-12 h-12 flex items-center justify-center bg-white/10 hover:bg-white/20 backdrop-blur-xl border border-white/10 rounded-full text-white text-xs font-bold transition-all"
title={objectFit === 'contain' ? 'Fill Screen' : 'Fit Content'}
>
{objectFit === 'contain' ? '⛶' : '⊡'}
</button>
{/* Mute Toggle */}
<button
onClick={toggleMute}
className="w-12 h-12 flex items-center justify-center bg-white/10 hover:bg-white/20 backdrop-blur-xl border border-white/10 rounded-full text-white transition-all"
title={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button>
</div>
{/* Author Info */}
<div className="absolute bottom-10 left-4 right-20 z-10">
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
onAuthorClick?.(video.author);
}}
className="text-white font-semibold text-sm truncate hover:text-cyan-400 transition-colors inline-flex items-center gap-1"
>
@{video.author}
<svg className="w-3 h-3 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
</button>
{video.views && (
<span className="text-white/40 text-xs">
{video.views >= 1000000
? `${(video.views / 1000000).toFixed(1)}M views`
: video.views >= 1000
? `${(video.views / 1000).toFixed(0)}K views`
: `${video.views} views`
}
</span>
)}
</div>
{video.description && (
<p className="text-white/70 text-xs line-clamp-2 mt-1">
{video.description}
</p>
)}
</div>
{/* Bottom Gradient */}
<div className="absolute bottom-0 left-0 w-full h-32 bg-gradient-to-t from-black/80 to-transparent pointer-events-none" />
</div>
);
};