refactor(player): switch to ArtPlayer-only architecture
- Remove fallback to other players in UnifiedVideoPlayer component - Eliminate legacy InlineVideoPlayer and VideoViewer components - Update API to always use ArtPlayer as player type and remove alternatives - Simplify feature detection flags to assume full ArtPlayer support - Remove ArtPlayer error fallback chain; log errors without switching player - Rename and adjust styles in ArtPlayerWrapper for consistency - Refactor VideoPlayerDebug to always report ArtPlayer active - Adjust diagnostics and logging to indicate ArtPlayer-only mode - Clean up unused imports and feature flags related to multiple players
This commit is contained in:
parent
86f4d47be1
commit
5e45db122e
|
|
@ -67,16 +67,18 @@ if (fs.existsSync(artPlayerPath)) {
|
|||
console.log('❌ ArtPlayer wrapper file not found');
|
||||
}
|
||||
|
||||
// Check unified video player
|
||||
console.log('\n🔄 Checking Unified Video Player:');
|
||||
// Check unified video player (ArtPlayer only)
|
||||
console.log('\n🔄 Checking Unified Video Player (ArtPlayer Only):');
|
||||
const unifiedPlayerPath = path.join(process.cwd(), 'src/components/unified-video-player.tsx');
|
||||
if (fs.existsSync(unifiedPlayerPath)) {
|
||||
const content = fs.readFileSync(unifiedPlayerPath, 'utf8');
|
||||
|
||||
const hasFallbackChain = content.includes('handleArtPlayerError');
|
||||
const hasArtPlayerOnly = content.includes('Always use ArtPlayer now');
|
||||
const noFallbacks = !content.includes('VideoViewer') && !content.includes('InlineVideoPlayer');
|
||||
const hasFormatDetection = content.includes('detectVideoFormat');
|
||||
|
||||
console.log(`${hasFallbackChain ? '✅' : '❌'} Fallback chain: ${hasFallbackChain ? 'IMPLEMENTED' : 'MISSING'}`);
|
||||
console.log(`${hasArtPlayerOnly ? '✅' : '❌'} ArtPlayer-only architecture: ${hasArtPlayerOnly ? 'IMPLEMENTED' : 'MISSING'}`);
|
||||
console.log(`${noFallbacks ? '✅' : '❌'} Legacy player removal: ${noFallbacks ? 'COMPLETE' : 'INCOMPLETE'}`);
|
||||
console.log(`${hasFormatDetection ? '✅' : '❌'} Format detection: ${hasFormatDetection ? 'INTEGRATED' : 'MISSING'}`);
|
||||
} else {
|
||||
console.log('❌ Unified video player file not found');
|
||||
|
|
@ -201,4 +203,5 @@ module.exports = {
|
|||
recommendations: issues.length > 0 ? issues : ['All systems ready for testing']
|
||||
};
|
||||
}
|
||||
}; }
|
||||
};
|
||||
|
|
@ -29,7 +29,6 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
|||
|
||||
// Detect optimal format and player
|
||||
const format = detectVideoFormat(video);
|
||||
const optimalPlayer = getOptimalPlayerType(format);
|
||||
|
||||
// Get additional video metadata
|
||||
let codecInfo = {};
|
||||
|
|
@ -57,24 +56,19 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
|||
},
|
||||
format: format,
|
||||
player: {
|
||||
type: optimalPlayer,
|
||||
recommended: optimalPlayer,
|
||||
alternatives: optimalPlayer === 'artplayer' ? ['current'] : ['artplayer'],
|
||||
type: 'artplayer',
|
||||
recommended: 'artplayer',
|
||||
alternatives: [], // No alternatives - ArtPlayer only
|
||||
features: {
|
||||
artplayer: {
|
||||
supported: format.supportLevel === 'native' || format.type === 'hls',
|
||||
supported: true, // ArtPlayer supports all formats now
|
||||
quality_control: format.type === 'hls',
|
||||
adaptive_streaming: format.type === 'hls',
|
||||
subtitle_support: true,
|
||||
pip_support: true,
|
||||
playback_rate: true
|
||||
},
|
||||
current: {
|
||||
supported: true,
|
||||
transcoding: format.type === 'fallback',
|
||||
anti_jitter: true,
|
||||
protected_duration: true,
|
||||
seek_optimization: format.type === 'fallback'
|
||||
playback_rate: true,
|
||||
autoplay: true,
|
||||
enhanced_controls: true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -111,9 +105,9 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
|||
|
||||
const { playerType, quality, volume, muted } = await request.json();
|
||||
|
||||
// Validate player type
|
||||
if (playerType && !['artplayer', 'current'].includes(playerType)) {
|
||||
return NextResponse.json({ error: 'Invalid player type' }, { status: 400 });
|
||||
// Validate player type (only artplayer is supported now)
|
||||
if (playerType && playerType !== 'artplayer') {
|
||||
return NextResponse.json({ error: 'Only ArtPlayer is supported' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Here you could store user preferences in the database
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export default function ArtPlayerWrapper({
|
|||
if (!useArtPlayer || !isOpen || !containerRef.current) return;
|
||||
|
||||
// Inject custom styles to remove shadows
|
||||
const styleId = 'artplayer-custom-styles';
|
||||
const styleId = 'artplayer-styles';
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
|
|
@ -107,6 +107,12 @@ export default function ArtPlayerWrapper({
|
|||
muted: false,
|
||||
volume: volume,
|
||||
|
||||
// Video quality settings to prevent blurriness
|
||||
preload: 'metadata',
|
||||
poster: '', // Clear any poster that might interfere
|
||||
airplay: true,
|
||||
loop: false,
|
||||
|
||||
// UI controls
|
||||
fullscreen: true,
|
||||
fullscreenWeb: true,
|
||||
|
|
@ -489,7 +495,7 @@ export default function ArtPlayerWrapper({
|
|||
hlsErrorHandlerRef.current = null;
|
||||
}
|
||||
// Clean up custom styles
|
||||
const styleElement = document.getElementById('artplayer-custom-styles');
|
||||
const styleElement = document.getElementById('artplayer-styles');
|
||||
if (styleElement) {
|
||||
styleElement.remove();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,548 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { X, Play, Pause, Maximize, Minimize, Volume2, VolumeX, Bookmark, Star, Heart } from 'lucide-react';
|
||||
|
||||
interface InlineVideoPlayerProps {
|
||||
video: {
|
||||
id: number;
|
||||
title: string;
|
||||
path: string;
|
||||
size: number;
|
||||
thumbnail: string;
|
||||
};
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
scrollPosition?: number;
|
||||
}
|
||||
|
||||
export default function InlineVideoPlayer({ video, isOpen, onClose, scrollPosition }: InlineVideoPlayerProps) {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [isBookmarked, setIsBookmarked] = useState(false);
|
||||
const [userRating, setUserRating] = useState(0);
|
||||
const [avgRating, setAvgRating] = useState(0);
|
||||
const [bookmarkCount, setBookmarkCount] = useState(0);
|
||||
const [starCount, setStarCount] = useState(0);
|
||||
const [showRating, setShowRating] = useState(false);
|
||||
const [isTranscoding, setIsTranscoding] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// Heartbeat mechanism
|
||||
const playerId = useRef(`player_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
|
||||
const heartbeatInterval = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Start heartbeat when player opens
|
||||
const startHeartbeat = () => {
|
||||
if (heartbeatInterval.current) {
|
||||
clearInterval(heartbeatInterval.current);
|
||||
}
|
||||
|
||||
heartbeatInterval.current = setInterval(async () => {
|
||||
try {
|
||||
await fetch('/api/heartbeat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
playerId: playerId.current,
|
||||
videoId: video.id
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Heartbeat failed:', error);
|
||||
}
|
||||
}, 5000); // Send heartbeat every 5 seconds
|
||||
};
|
||||
|
||||
// Stop heartbeat when player closes
|
||||
const stopHeartbeat = async () => {
|
||||
if (heartbeatInterval.current) {
|
||||
clearInterval(heartbeatInterval.current);
|
||||
heartbeatInterval.current = null;
|
||||
}
|
||||
|
||||
// Notify backend that player is disconnected
|
||||
try {
|
||||
await fetch('/api/heartbeat', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
playerId: playerId.current
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to notify heartbeat disconnect:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIsVisible(true);
|
||||
loadBookmarkStatus();
|
||||
loadStarRating();
|
||||
startHeartbeat(); // Start heartbeat when player opens
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
stopHeartbeat(); // Stop heartbeat when player closes
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Cleanup heartbeat on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopHeartbeat();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && videoRef.current) {
|
||||
// First try direct streaming, fallback to transcoding if needed
|
||||
videoRef.current.src = `/api/stream/${video.id}`;
|
||||
videoRef.current.load();
|
||||
|
||||
// Handle video load errors (fallback to transcoding)
|
||||
const handleError = () => {
|
||||
console.log('Video load failed, trying transcoded version...');
|
||||
if (videoRef.current) {
|
||||
setIsTranscoding(true);
|
||||
videoRef.current.src = `/api/stream/${video.id}/transcode`;
|
||||
videoRef.current.load();
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-play when video is loaded
|
||||
const handleLoadedData = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.play().then(() => {
|
||||
setIsPlaying(true);
|
||||
}).catch((error) => {
|
||||
console.log('Auto-play prevented by browser:', error);
|
||||
// Auto-play might be blocked by browser, that's okay
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle metadata loaded to get duration
|
||||
const handleLoadedMetadata = () => {
|
||||
if (videoRef.current) {
|
||||
const videoDuration = videoRef.current.duration;
|
||||
if (videoDuration && videoDuration > 0 && !isNaN(videoDuration)) {
|
||||
console.log(`[PLAYER] Duration from metadata: ${videoDuration}s`);
|
||||
setDuration(videoDuration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle response headers to get duration for transcoded streams
|
||||
const handleResponseHeaders = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/stream/${video.id}${isTranscoding ? '/transcode' : ''}`);
|
||||
const contentDuration = response.headers.get('X-Content-Duration');
|
||||
if (contentDuration) {
|
||||
const durationValue = parseFloat(contentDuration);
|
||||
if (durationValue > 0 && !isNaN(durationValue)) {
|
||||
console.log(`[PLAYER] Duration from headers: ${durationValue}s (transcoded: ${isTranscoding})`);
|
||||
setDuration(durationValue);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Could not fetch duration from headers:', error);
|
||||
}
|
||||
};
|
||||
|
||||
videoRef.current.addEventListener('loadeddata', handleLoadedData);
|
||||
videoRef.current.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
videoRef.current.addEventListener('error', handleError);
|
||||
|
||||
// Try to get duration from headers
|
||||
handleResponseHeaders();
|
||||
|
||||
return () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.removeEventListener('loadeddata', handleLoadedData);
|
||||
videoRef.current.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
videoRef.current.removeEventListener('error', handleError);
|
||||
videoRef.current.pause();
|
||||
videoRef.current.src = '';
|
||||
videoRef.current.load();
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [isOpen, video.id, isTranscoding]);
|
||||
|
||||
// Fetch duration when transcoding state changes
|
||||
useEffect(() => {
|
||||
if (isTranscoding) {
|
||||
const fetchTranscodedDuration = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/stream/${video.id}/transcode`);
|
||||
const contentDuration = response.headers.get('X-Content-Duration');
|
||||
if (contentDuration) {
|
||||
const durationValue = parseFloat(contentDuration);
|
||||
if (durationValue > 0 && !isNaN(durationValue)) {
|
||||
console.log(`[PLAYER] Transcoding duration: ${durationValue}s`);
|
||||
setDuration(durationValue);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Could not fetch transcoded duration:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTranscodedDuration();
|
||||
}
|
||||
}, [isTranscoding, video.id]);
|
||||
|
||||
// Cleanup transcoding process
|
||||
const cleanupTranscoding = async () => {
|
||||
if (isTranscoding) {
|
||||
try {
|
||||
await fetch(`/api/stream/${video.id}/transcode`, { method: 'DELETE' });
|
||||
console.log('Transcoding process cleaned up');
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up transcoding process:', error);
|
||||
}
|
||||
setIsTranscoding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (videoRef.current) {
|
||||
if (isPlaying) {
|
||||
videoRef.current.pause();
|
||||
} else {
|
||||
videoRef.current.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMute = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.muted = !isMuted;
|
||||
setIsMuted(!isMuted);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (videoRef.current) {
|
||||
const newVolume = parseFloat(e.target.value);
|
||||
videoRef.current.volume = newVolume;
|
||||
setVolume(newVolume);
|
||||
setIsMuted(newVolume === 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (videoRef.current) {
|
||||
setCurrentTime(videoRef.current.currentTime);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (videoRef.current) {
|
||||
const videoDuration = videoRef.current.duration;
|
||||
if (videoDuration && videoDuration > 0) {
|
||||
setDuration(videoDuration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (videoRef.current && duration > 0) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left;
|
||||
const newTime = (clickX / rect.width) * duration;
|
||||
videoRef.current.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const loadBookmarkStatus = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/bookmarks?mediaId=${video.id}`);
|
||||
const data = await response.json();
|
||||
setIsBookmarked(data.length > 0);
|
||||
} catch (error) {
|
||||
console.error('Error loading bookmark status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStarRating = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/stars?mediaId=${video.id}`);
|
||||
const stars = await response.json();
|
||||
|
||||
// Get media info for counts and avg rating
|
||||
const mediaResponse = await fetch(`/api/videos/${video.id}`);
|
||||
const mediaData = await mediaResponse.json();
|
||||
|
||||
setBookmarkCount(mediaData.bookmark_count || 0);
|
||||
setStarCount(mediaData.star_count || 0);
|
||||
setAvgRating(mediaData.avg_rating || 0);
|
||||
|
||||
// Set user's rating if exists
|
||||
if (stars.length > 0) {
|
||||
setUserRating(stars[0].rating);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading star rating:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleBookmark = async () => {
|
||||
try {
|
||||
if (isBookmarked) {
|
||||
// Remove bookmark
|
||||
const response = await fetch(`/api/bookmarks/${video.id}`, { method: 'DELETE' });
|
||||
if (response.ok) {
|
||||
setIsBookmarked(false);
|
||||
setBookmarkCount(prev => Math.max(0, prev - 1));
|
||||
}
|
||||
} else {
|
||||
// Add bookmark
|
||||
const response = await fetch(`/api/bookmarks/${video.id}`, { method: 'POST' });
|
||||
if (response.ok) {
|
||||
setIsBookmarked(true);
|
||||
setBookmarkCount(prev => prev + 1);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling bookmark:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStarClick = async (rating: number) => {
|
||||
try {
|
||||
const response = await fetch('/api/stars', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mediaId: video.id, rating })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setUserRating(rating);
|
||||
// Reload star data
|
||||
await loadStarRating();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting star rating:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handlePlayPause();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
// Prevent body scroll when player is open
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
// Restore body scroll when player is closed
|
||||
document.body.style.overflow = 'unset';
|
||||
// Cleanup transcoding when component unmounts
|
||||
cleanupTranscoding();
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 z-50 bg-background transition-opacity duration-300 flex flex-col ${isVisible ? 'opacity-100' : 'opacity-0'}`}>
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 bg-background border-b border-border">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-foreground truncate max-w-md">
|
||||
{video.title}
|
||||
</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Transcoding indicator */}
|
||||
{isTranscoding && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-yellow-500/20 text-yellow-600 rounded-full">
|
||||
<div className="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm">Transcoding</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bookmark Button */}
|
||||
<button
|
||||
onClick={toggleBookmark}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full transition-colors ${
|
||||
isBookmarked
|
||||
? 'bg-blue-500 text-white hover:bg-blue-600'
|
||||
: 'bg-muted hover:bg-muted/80'
|
||||
}`}
|
||||
>
|
||||
<Bookmark className={`h-4 w-4 ${isBookmarked ? 'fill-current' : ''}`} />
|
||||
<span className="text-sm">{bookmarkCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Star Rating */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
onClick={() => handleStarClick(star)}
|
||||
className="text-yellow-500 hover:text-yellow-400 transition-colors"
|
||||
>
|
||||
<Star
|
||||
className={`h-4 w-4 ${
|
||||
star <= userRating ? 'fill-current' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{avgRating.toFixed(1)} ({starCount})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Player */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-7xl mx-auto px-6 py-6">
|
||||
<div className="aspect-video bg-black rounded-lg overflow-hidden relative group">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-contain"
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onMouseMove={() => setShowControls(true)}
|
||||
onMouseLeave={() => setShowControls(false)}
|
||||
controls={false}
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
|
||||
{/* Video Overlay Controls */}
|
||||
<div className={`absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`}>
|
||||
{/* Center Play Button */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<button
|
||||
onClick={handlePlayPause}
|
||||
className="w-20 h-20 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white/30 transition-all duration-200"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-8 w-8 text-white" />
|
||||
) : (
|
||||
<Play className="h-8 w-8 text-white ml-1" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bottom Controls */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||||
{/* Progress Bar */}
|
||||
<div
|
||||
className="relative h-2 bg-white/20 rounded-full cursor-pointer mb-4"
|
||||
onClick={handleProgressClick}
|
||||
>
|
||||
<div
|
||||
className="absolute h-full bg-white rounded-full"
|
||||
style={{ width: `${(currentTime / duration) * 100 || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handlePlayPause}
|
||||
className="p-2 hover:bg-white/20 rounded-full transition-colors"
|
||||
>
|
||||
{isPlaying ? <Pause className="h-5 w-5 text-white" /> : <Play className="h-5 w-5 text-white" />}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleMute}
|
||||
className="p-2 hover:bg-white/20 rounded-full transition-colors"
|
||||
>
|
||||
{isMuted ? <VolumeX className="h-5 w-5 text-white" /> : <Volume2 className="h-5 w-5 text-white" />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-24 h-1 bg-white/20 rounded-full appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-white">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-white">
|
||||
{Math.round(video.size / 1024 / 1024)} MB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video info below player */}
|
||||
<div className="mt-6 space-y-3 pb-8">
|
||||
<h2 className="text-2xl font-bold text-foreground break-words leading-tight">{video.title}</h2>
|
||||
<div className="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||
<p className="text-muted-foreground font-mono text-sm break-words leading-relaxed">
|
||||
{video.path}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
File size: {Math.round(video.size / 1024 / 1024)} MB
|
||||
</p>
|
||||
{duration > 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Duration: {formatTime(duration)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { detectVideoFormat, getOptimalPlayerType, VideoFile } from '@/lib/video-format-detector';
|
||||
import { detectVideoFormat, VideoFile } from '@/lib/video-format-detector';
|
||||
import ArtPlayerWrapper from '@/components/artplayer-wrapper';
|
||||
import VideoViewer from '@/components/video-viewer';
|
||||
import InlineVideoPlayer from '@/components/inline-video-player';
|
||||
|
||||
interface UnifiedVideoPlayerProps {
|
||||
video: VideoFile;
|
||||
|
|
@ -28,7 +26,7 @@ export default function UnifiedVideoPlayer({
|
|||
isOpen,
|
||||
onClose,
|
||||
playerType = 'modal',
|
||||
useArtPlayer: forceArtPlayer = false,
|
||||
useArtPlayer: forceArtPlayer = true, // Always use ArtPlayer now
|
||||
onProgress,
|
||||
onBookmark,
|
||||
onUnbookmark,
|
||||
|
|
@ -40,34 +38,24 @@ export default function UnifiedVideoPlayer({
|
|||
autoplay = true
|
||||
}: UnifiedVideoPlayerProps) {
|
||||
const [format, setFormat] = useState<ReturnType<typeof detectVideoFormat> | null>(null);
|
||||
const [useArtPlayer, setUseArtPlayer] = useState(forceArtPlayer);
|
||||
const [artPlayerError, setArtPlayerError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Detect format and determine optimal player on mount
|
||||
// Detect format on mount
|
||||
useEffect(() => {
|
||||
if (video) {
|
||||
const detectedFormat = detectVideoFormat(video);
|
||||
setFormat(detectedFormat);
|
||||
|
||||
// Determine optimal player if not forced
|
||||
if (!forceArtPlayer) {
|
||||
const optimalPlayer = getOptimalPlayerType(detectedFormat);
|
||||
setUseArtPlayer(optimalPlayer === 'artplayer');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [video, forceArtPlayer]);
|
||||
}, [video]);
|
||||
|
||||
// Handle ArtPlayer errors by falling back to current player
|
||||
const handleArtPlayerError = useCallback(() => {
|
||||
console.log('ArtPlayer encountered error, attempting fallback chain...');
|
||||
// Handle ArtPlayer errors with recovery
|
||||
const handleArtPlayerError = useCallback((error: string) => {
|
||||
console.log('ArtPlayer encountered error:', error);
|
||||
|
||||
// First fallback: Try direct streaming if HLS failed
|
||||
// Try to recover by using direct streaming if HLS failed
|
||||
if (format?.type === 'hls') {
|
||||
console.log('HLS failed, trying direct streaming fallback...');
|
||||
// Try to use direct streaming URL instead of HLS
|
||||
const directFormat = {
|
||||
...format,
|
||||
type: 'direct' as const,
|
||||
|
|
@ -75,12 +63,9 @@ export default function UnifiedVideoPlayer({
|
|||
supportLevel: 'native' as const
|
||||
};
|
||||
setFormat(directFormat);
|
||||
// Keep using ArtPlayer with direct URL
|
||||
} else {
|
||||
// Final fallback: Use current player system
|
||||
console.log('Direct streaming also failed, falling back to current player system');
|
||||
setArtPlayerError(true);
|
||||
setUseArtPlayer(false);
|
||||
console.log('ArtPlayer error with direct streaming, logging only');
|
||||
// Just log the error, no more fallbacks needed
|
||||
}
|
||||
}, [format, video.id]);
|
||||
|
||||
|
|
@ -105,102 +90,28 @@ export default function UnifiedVideoPlayer({
|
|||
}
|
||||
}, [onRate]);
|
||||
|
||||
// Render appropriate player based on configuration
|
||||
// Always render ArtPlayer (no more fallbacks)
|
||||
const renderPlayer = () => {
|
||||
// If ArtPlayer failed, always use current player
|
||||
if (artPlayerError) {
|
||||
return renderCurrentPlayer();
|
||||
}
|
||||
|
||||
// If forced to use ArtPlayer, use it regardless of format
|
||||
if (forceArtPlayer && useArtPlayer) {
|
||||
return renderArtPlayer();
|
||||
}
|
||||
|
||||
// Otherwise, use optimal player based on format detection
|
||||
if (useArtPlayer) {
|
||||
return renderArtPlayer();
|
||||
} else {
|
||||
return renderCurrentPlayer();
|
||||
}
|
||||
};
|
||||
|
||||
// Render ArtPlayer
|
||||
const renderArtPlayer = () => {
|
||||
if (playerType === 'modal') {
|
||||
return (
|
||||
<ArtPlayerWrapper
|
||||
video={video}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onProgress={handleProgressUpdate}
|
||||
onBookmark={handleBookmarkToggle}
|
||||
onUnbookmark={onUnbookmark}
|
||||
onRate={handleRatingUpdate}
|
||||
onError={handleArtPlayerError}
|
||||
useArtPlayer={true}
|
||||
isBookmarked={(video.bookmark_count || 0) > 0}
|
||||
bookmarkCount={video.bookmark_count || 0}
|
||||
avgRating={video.avg_rating || 0}
|
||||
showBookmarks={showBookmarks}
|
||||
showRatings={showRatings}
|
||||
autoplay={autoplay}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// For inline player, we need to adapt the interface
|
||||
console.warn('ArtPlayer inline mode not yet implemented, falling back to modal');
|
||||
return renderArtPlayer();
|
||||
}
|
||||
};
|
||||
|
||||
// Render current player (VideoViewer or InlineVideoPlayer)
|
||||
const renderCurrentPlayer = () => {
|
||||
const videoItem = {
|
||||
id: video.id,
|
||||
title: video.title,
|
||||
path: video.path,
|
||||
size: video.size,
|
||||
thumbnail: video.thumbnail,
|
||||
type: video.type,
|
||||
bookmark_count: video.bookmark_count || 0,
|
||||
avg_rating: video.avg_rating || 0,
|
||||
star_count: video.star_count || 0
|
||||
};
|
||||
|
||||
if (playerType === 'modal') {
|
||||
return (
|
||||
<VideoViewer
|
||||
video={videoItem}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
showBookmarks={showBookmarks}
|
||||
showRatings={showRatings}
|
||||
formatFileSize={formatFileSize}
|
||||
onBookmark={onBookmark}
|
||||
onUnbookmark={onUnbookmark}
|
||||
onRate={onRate}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<InlineVideoPlayer
|
||||
video={videoItem}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
scrollPosition={scrollPosition}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Format file size utility
|
||||
const defaultFormatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
// Always use ArtPlayer for both modal and inline modes
|
||||
return (
|
||||
<ArtPlayerWrapper
|
||||
video={video}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onProgress={handleProgressUpdate}
|
||||
onBookmark={handleBookmarkToggle}
|
||||
onUnbookmark={onUnbookmark}
|
||||
onRate={handleRatingUpdate}
|
||||
onError={handleArtPlayerError}
|
||||
useArtPlayer={true}
|
||||
isBookmarked={(video.bookmark_count || 0) > 0}
|
||||
bookmarkCount={video.bookmark_count || 0}
|
||||
avgRating={video.avg_rating || 0}
|
||||
showBookmarks={showBookmarks}
|
||||
showRatings={showRatings}
|
||||
autoplay={autoplay}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
|
|
@ -208,7 +119,7 @@ export default function UnifiedVideoPlayer({
|
|||
<div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center">
|
||||
<div className="text-white text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white mx-auto mb-4"></div>
|
||||
<p>Detecting optimal player...</p>
|
||||
<p>Loading ArtPlayer...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -216,10 +127,10 @@ export default function UnifiedVideoPlayer({
|
|||
|
||||
return (
|
||||
<div className="unified-video-player">
|
||||
{/* Player selection indicator (for debugging) */}
|
||||
{/* ArtPlayer indicator (for debugging) */}
|
||||
{process.env.NODE_ENV === 'development' && format && (
|
||||
<div className="fixed top-4 left-4 z-50 bg-blue-500/20 text-blue-400 rounded-full px-3 py-1.5 text-xs">
|
||||
{useArtPlayer ? 'ArtPlayer' : 'Current Player'} - {format.supportLevel}
|
||||
ArtPlayer - {format.supportLevel}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -228,5 +139,5 @@ export default function UnifiedVideoPlayer({
|
|||
);
|
||||
}
|
||||
|
||||
// Re-export the hook for external use
|
||||
export { detectVideoFormat, getOptimalPlayerType };
|
||||
// Re-export for external use
|
||||
export { detectVideoFormat };
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getFeatureFlags, shouldUseArtPlayer } from '@/lib/feature-flags';
|
||||
import { detectVideoFormat } from '@/lib/video-format-detector';
|
||||
|
||||
interface VideoPlayerDebugProps {
|
||||
|
|
@ -11,25 +10,19 @@ interface VideoPlayerDebugProps {
|
|||
path: string;
|
||||
type?: string;
|
||||
};
|
||||
useArtPlayer?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function VideoPlayerDebug({ video, useArtPlayer, className = '' }: VideoPlayerDebugProps) {
|
||||
export default function VideoPlayerDebug({ video, className = '' }: VideoPlayerDebugProps) {
|
||||
const [debugInfo, setDebugInfo] = useState({
|
||||
playerType: 'unknown',
|
||||
playerType: 'artplayer', // Always ArtPlayer now
|
||||
format: null as any,
|
||||
featureFlags: null as any,
|
||||
userId: 'test-user',
|
||||
reason: ''
|
||||
reason: 'ArtPlayer is the only supported player'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!video) return;
|
||||
|
||||
// Get feature flags
|
||||
const flags = getFeatureFlags(debugInfo.userId, video.id.toString());
|
||||
|
||||
// Detect video format
|
||||
const format = detectVideoFormat({
|
||||
id: video.id,
|
||||
|
|
@ -40,29 +33,12 @@ export default function VideoPlayerDebug({ video, useArtPlayer, className = '' }
|
|||
type: video.type || 'video'
|
||||
});
|
||||
|
||||
// Determine which player will be used
|
||||
let playerType = 'current';
|
||||
let reason = '';
|
||||
|
||||
if (useArtPlayer) {
|
||||
playerType = 'artplayer';
|
||||
reason = 'Forced via useArtPlayer prop';
|
||||
} else if (shouldUseArtPlayer(debugInfo.userId, video.id.toString(), video.path.split('.').pop())) {
|
||||
playerType = 'artplayer';
|
||||
reason = 'Enabled via feature flag';
|
||||
} else {
|
||||
playerType = 'current';
|
||||
reason = 'Using current player (ArtPlayer not enabled)';
|
||||
}
|
||||
|
||||
setDebugInfo({
|
||||
playerType,
|
||||
playerType: 'artplayer',
|
||||
format,
|
||||
featureFlags: flags,
|
||||
userId: debugInfo.userId,
|
||||
reason
|
||||
reason: 'ArtPlayer is the only supported player'
|
||||
});
|
||||
}, [video, useArtPlayer]);
|
||||
}, [video]);
|
||||
|
||||
if (!video) return null;
|
||||
|
||||
|
|
@ -97,7 +73,7 @@ export default function VideoPlayerDebug({ video, useArtPlayer, className = '' }
|
|||
}
|
||||
|
||||
/**
|
||||
* Test component to verify ArtPlayer integration
|
||||
* Test component to show ArtPlayer is always active
|
||||
*/
|
||||
export function ArtPlayerTestBanner() {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
|
@ -105,10 +81,10 @@ export function ArtPlayerTestBanner() {
|
|||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 right-0 bg-gradient-to-r from-blue-600 to-purple-600 text-white p-2 text-center text-sm z-50">
|
||||
<div className="fixed top-0 left-0 right-0 bg-gradient-to-r from-green-600 to-blue-600 text-white p-2 text-center text-sm z-50">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="font-bold">🎬 ArtPlayer Integration Active!</span>
|
||||
<span>- Modern video player with enhanced features</span>
|
||||
<span className="font-bold">🎬 ArtPlayer Only Mode!</span>
|
||||
<span>- Clean, consistent video experience across all formats</span>
|
||||
<button
|
||||
onClick={() => setIsVisible(false)}
|
||||
className="ml-4 text-white hover:text-gray-200"
|
||||
|
|
@ -121,24 +97,12 @@ export function ArtPlayerTestBanner() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Development helper to force ArtPlayer usage
|
||||
* Simple helper that always returns ArtPlayer
|
||||
*/
|
||||
export function useArtPlayerDebug(userId?: string, videoId?: string) {
|
||||
const [forceArtPlayer, setForceArtPlayer] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check URL parameter for forcing ArtPlayer
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const forceParam = urlParams.get('forceArtPlayer');
|
||||
|
||||
if (forceParam === 'true') {
|
||||
setForceArtPlayer(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
export function useArtPlayerDebug() {
|
||||
return {
|
||||
forceArtPlayer,
|
||||
shouldUseArtPlayer: forceArtPlayer || shouldUseArtPlayer(userId, videoId)
|
||||
forceArtPlayer: true,
|
||||
shouldUseArtPlayer: true
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,741 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { X, Play, Pause, Volume2, VolumeX, Maximize, Star, Bookmark } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useProtectedDuration } from '@/lib/hooks/use-protected-duration';
|
||||
import { useStableProgress, formatTime } from '@/lib/hooks/use-stable-progress';
|
||||
|
||||
interface Video {
|
||||
id: number;
|
||||
title: string;
|
||||
path: string;
|
||||
size: number;
|
||||
thumbnail: string;
|
||||
type: string;
|
||||
bookmark_count: number;
|
||||
avg_rating: number;
|
||||
star_count: number;
|
||||
}
|
||||
|
||||
interface FileSystemItem {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
size: number;
|
||||
thumbnail?: string;
|
||||
type?: string;
|
||||
id?: number;
|
||||
}
|
||||
|
||||
interface VideoViewerProps {
|
||||
video: Video | FileSystemItem;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
showBookmarks?: boolean;
|
||||
showRatings?: boolean;
|
||||
formatFileSize?: (bytes: number) => string;
|
||||
onBookmark?: (videoId: number) => void;
|
||||
onUnbookmark?: (videoId: number) => void;
|
||||
onRate?: (videoId: number, rating: number) => void;
|
||||
}
|
||||
|
||||
export default function VideoViewer({
|
||||
video,
|
||||
isOpen,
|
||||
onClose,
|
||||
showBookmarks = false,
|
||||
showRatings = false,
|
||||
formatFileSize,
|
||||
onBookmark,
|
||||
onUnbookmark,
|
||||
onRate
|
||||
}: VideoViewerProps) {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [isBookmarked, setIsBookmarked] = useState(false);
|
||||
const [bookmarkCount, setBookmarkCount] = useState(0);
|
||||
const [isTranscoding, setIsTranscoding] = useState(false);
|
||||
const [transcodingError, setTranscodingError] = useState<string | null>(null);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const videoRef = useRef<HTMLVideoElement>(null!);
|
||||
const lastTranscodingUrlRef = useRef<string | null>(null);
|
||||
|
||||
// Use protected duration hook for accurate duration display
|
||||
const {
|
||||
duration,
|
||||
isLoading: isDurationLoading,
|
||||
error: durationError,
|
||||
handleDurationChange: protectedHandleDurationChange,
|
||||
refreshDuration
|
||||
} = useProtectedDuration({
|
||||
videoId: video && 'id' in video && video.id !== undefined ? video.id.toString() : ''
|
||||
});
|
||||
|
||||
// Use stable progress hook for anti-jitter
|
||||
const {
|
||||
currentTime,
|
||||
bufferState,
|
||||
isDragging,
|
||||
handleTimeUpdate: stableHandleTimeUpdate,
|
||||
handleProgress: stableHandleProgress,
|
||||
handleSeek: stableHandleSeek,
|
||||
handleSeekStart: stableHandleSeekStart,
|
||||
handleSeekEnd: stableHandleSeekEnd,
|
||||
resetProgress
|
||||
} = useStableProgress(videoRef, duration);
|
||||
|
||||
// Heartbeat mechanism
|
||||
const playerId = useRef(`player_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
|
||||
const heartbeatInterval = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Start heartbeat when player opens
|
||||
const startHeartbeat = () => {
|
||||
if (heartbeatInterval.current) {
|
||||
clearInterval(heartbeatInterval.current);
|
||||
}
|
||||
|
||||
heartbeatInterval.current = setInterval(async () => {
|
||||
try {
|
||||
const videoId = getVideoId();
|
||||
if (videoId) {
|
||||
await fetch('/api/heartbeat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
playerId: playerId.current,
|
||||
videoId: videoId
|
||||
})
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Heartbeat failed:', error);
|
||||
}
|
||||
}, 5000); // Send heartbeat every 5 seconds
|
||||
};
|
||||
|
||||
// Stop heartbeat when player closes
|
||||
const stopHeartbeat = async () => {
|
||||
if (heartbeatInterval.current) {
|
||||
clearInterval(heartbeatInterval.current);
|
||||
heartbeatInterval.current = null;
|
||||
}
|
||||
|
||||
// Notify backend that player is disconnected
|
||||
try {
|
||||
await fetch('/api/heartbeat', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
playerId: playerId.current
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to notify heartbeat disconnect:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Update local bookmark state when video changes
|
||||
useEffect(() => {
|
||||
if (video && 'bookmark_count' in video) {
|
||||
setIsBookmarked(video.bookmark_count > 0);
|
||||
setBookmarkCount(video.bookmark_count);
|
||||
}
|
||||
}, [video]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
startHeartbeat(); // Start heartbeat when player opens
|
||||
} else {
|
||||
stopHeartbeat(); // Stop heartbeat when player closes
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Cleanup heartbeat on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopHeartbeat();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && videoRef.current && video) {
|
||||
const videoId = getVideoId();
|
||||
if (!videoId) return;
|
||||
|
||||
// Reset hooks for new video
|
||||
resetProgress();
|
||||
// Let the useProtectedDuration hook handle duration fetching internally
|
||||
|
||||
// First check codec info and apply H.264 direct streaming priority
|
||||
const checkTranscodingNeeded = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/videos/${videoId}`);
|
||||
const videoData = await response.json();
|
||||
|
||||
let codecInfo = { needsTranscoding: false, codec: '', container: '' };
|
||||
try {
|
||||
codecInfo = JSON.parse(videoData.codec_info || '{}');
|
||||
} catch {
|
||||
// Fallback if codec info is invalid
|
||||
}
|
||||
|
||||
// H.264 Direct Streaming Priority: Override database flag for H.264 content
|
||||
// According to memory: H.264-encoded content should attempt direct streaming first
|
||||
const isH264 = codecInfo.codec && ['h264', 'avc1', 'avc'].includes(codecInfo.codec.toLowerCase());
|
||||
const shouldAttemptDirect = isH264;
|
||||
|
||||
if (shouldAttemptDirect) {
|
||||
console.log(`[PLAYER] Video ${videoId} has H.264 codec (${codecInfo.codec}), attempting direct streaming first (overriding DB flag: ${codecInfo.needsTranscoding})`);
|
||||
setIsTranscoding(false);
|
||||
videoRef.current!.src = `/api/stream/${videoId}`;
|
||||
videoRef.current!.load();
|
||||
} else if (codecInfo.needsTranscoding) {
|
||||
console.log(`[PLAYER] Video ${videoId} needs transcoding (codec: ${codecInfo.codec}), using transcoding endpoint directly`);
|
||||
setIsTranscoding(true);
|
||||
setTranscodingError(null);
|
||||
const transcodingUrl = `/api/stream/${videoId}/transcode`;
|
||||
lastTranscodingUrlRef.current = transcodingUrl;
|
||||
videoRef.current!.src = transcodingUrl;
|
||||
videoRef.current!.load();
|
||||
} else {
|
||||
console.log(`[PLAYER] Video ${videoId} can be streamed directly (codec: ${codecInfo.codec})`);
|
||||
setIsTranscoding(false);
|
||||
videoRef.current!.src = `/api/stream/${videoId}`;
|
||||
videoRef.current!.load();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[PLAYER] Error checking transcoding needs:`, error);
|
||||
// Fallback to direct stream
|
||||
videoRef.current!.src = `/api/stream/${videoId}`;
|
||||
videoRef.current!.load();
|
||||
}
|
||||
};
|
||||
|
||||
checkTranscodingNeeded();
|
||||
|
||||
// Handle video load errors (simplified since we pre-check transcoding needs)
|
||||
const handleError = async () => {
|
||||
const currentSrc = videoRef.current?.src;
|
||||
const isAlreadyTranscoding = currentSrc?.includes('/transcode');
|
||||
|
||||
console.log(`[PLAYER] Video error, src: ${currentSrc}, transcoding: ${isAlreadyTranscoding}, retries: ${retryCount}`);
|
||||
|
||||
if (!isAlreadyTranscoding && retryCount < 2) {
|
||||
console.log('Direct stream failed, trying transcoded version...');
|
||||
setIsTranscoding(true);
|
||||
setTranscodingError(null);
|
||||
setRetryCount(prev => prev + 1);
|
||||
|
||||
// Clean up any existing transcoding streams first
|
||||
try {
|
||||
await fetch(`/api/stream/${videoId}/transcode`, { method: 'DELETE' });
|
||||
} catch (cleanupError) {
|
||||
console.log('Cleanup warning (non-critical):', cleanupError);
|
||||
}
|
||||
|
||||
// Wait a moment before starting new transcode
|
||||
setTimeout(() => {
|
||||
if (videoRef.current) {
|
||||
const transcodingUrl = `/api/stream/${videoId}/transcode?retry=${retryCount}`;
|
||||
lastTranscodingUrlRef.current = transcodingUrl;
|
||||
videoRef.current.src = transcodingUrl;
|
||||
videoRef.current.load();
|
||||
}
|
||||
}, 1000);
|
||||
} else if (isAlreadyTranscoding && retryCount < 3) {
|
||||
console.log('Transcoding error, retrying...');
|
||||
setRetryCount(prev => prev + 1);
|
||||
|
||||
// Clean up and retry transcoding
|
||||
try {
|
||||
await fetch(`/api/stream/${videoId}/transcode`, { method: 'DELETE' });
|
||||
} catch (cleanupError) {
|
||||
console.log('Cleanup warning (non-critical):', cleanupError);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (videoRef.current) {
|
||||
const transcodingUrl = `/api/stream/${videoId}/transcode?retry=${retryCount}`;
|
||||
lastTranscodingUrlRef.current = transcodingUrl;
|
||||
videoRef.current.src = transcodingUrl;
|
||||
videoRef.current.load();
|
||||
}
|
||||
}, 2000);
|
||||
} else {
|
||||
console.error('Maximum retry attempts reached');
|
||||
setTranscodingError('Failed to load video after multiple attempts. The video may be corrupted or in an unsupported format.');
|
||||
setIsTranscoding(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-play when video is loaded
|
||||
const handleLoadedData = () => {
|
||||
if (videoRef.current) {
|
||||
setTranscodingError(null); // Clear any previous errors
|
||||
setRetryCount(0); // Reset retry count on successful load
|
||||
|
||||
videoRef.current.play().then(() => {
|
||||
setIsPlaying(true);
|
||||
}).catch((error) => {
|
||||
console.log('Auto-play prevented by browser:', error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle metadata loaded to get duration (with protection)
|
||||
const handleLoadedMetadata = () => {
|
||||
if (videoRef.current) {
|
||||
const videoDuration = videoRef.current.duration;
|
||||
if (videoDuration && videoDuration > 0 && !isNaN(videoDuration)) {
|
||||
console.log(`[PLAYER] Metadata duration: ${videoDuration}s`);
|
||||
protectedHandleDurationChange(videoDuration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle duration change events (with protection)
|
||||
const handleDurationChange = () => {
|
||||
if (videoRef.current) {
|
||||
const videoDuration = videoRef.current.duration;
|
||||
if (videoDuration && videoDuration > 0 && !isNaN(videoDuration)) {
|
||||
console.log(`[PLAYER] Duration change: ${videoDuration}s`);
|
||||
protectedHandleDurationChange(videoDuration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
videoRef.current.addEventListener('loadeddata', handleLoadedData);
|
||||
videoRef.current.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
videoRef.current.addEventListener('durationchange', handleDurationChange);
|
||||
videoRef.current.addEventListener('error', handleError);
|
||||
|
||||
return () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.removeEventListener('loadeddata', handleLoadedData);
|
||||
videoRef.current.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
videoRef.current.removeEventListener('durationchange', handleDurationChange);
|
||||
videoRef.current.removeEventListener('error', handleError);
|
||||
videoRef.current.pause();
|
||||
videoRef.current.src = '';
|
||||
videoRef.current.load();
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [isOpen, video, isTranscoding]);
|
||||
|
||||
// Separate effect for hook event listeners to avoid infinite re-renders
|
||||
useEffect(() => {
|
||||
if (!isOpen || !videoRef.current) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
|
||||
// Add event listeners for the hooks
|
||||
video.addEventListener('timeupdate', stableHandleTimeUpdate);
|
||||
video.addEventListener('progress', stableHandleProgress);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', stableHandleTimeUpdate);
|
||||
video.removeEventListener('progress', stableHandleProgress);
|
||||
};
|
||||
}, [isOpen]); // Only depend on isOpen, not the functions
|
||||
|
||||
// Reset hooks when video changes
|
||||
useEffect(() => {
|
||||
if (isOpen && video) {
|
||||
resetProgress();
|
||||
// Don't call refreshDuration here - let the hook handle it internally
|
||||
}
|
||||
}, [isOpen, video]); // Remove function dependencies
|
||||
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
videoRef.current.currentTime = Math.min(
|
||||
videoRef.current.currentTime + 10,
|
||||
videoRef.current.duration || 0
|
||||
);
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
videoRef.current.currentTime = Math.max(
|
||||
videoRef.current.currentTime - 10,
|
||||
0
|
||||
);
|
||||
break;
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
handlePlayPause();
|
||||
break;
|
||||
case 'f':
|
||||
case 'F':
|
||||
e.preventDefault();
|
||||
handleFullscreen();
|
||||
break;
|
||||
case 'm':
|
||||
case 'M':
|
||||
e.preventDefault();
|
||||
handleMute();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (videoRef.current) {
|
||||
if (isPlaying) {
|
||||
videoRef.current.pause();
|
||||
} else {
|
||||
videoRef.current.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMute = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.muted = !isMuted;
|
||||
setIsMuted(!isMuted);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newVolume = parseFloat(e.target.value);
|
||||
setVolume(newVolume);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.volume = newVolume;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleSeek = async (newTime: number) => {
|
||||
const videoId = getVideoId();
|
||||
if (!videoId || !videoRef.current) return;
|
||||
|
||||
// For transcoded videos, use seek-optimized transcoding
|
||||
if (isTranscoding) {
|
||||
console.log(`[PLAYER] Seek-optimized transcoding to ${newTime}s`);
|
||||
|
||||
// Prevent multiple simultaneous requests
|
||||
const newTranscodingUrl = `/api/stream/${videoId}/transcode?seek=${newTime}&t=${Date.now()}`;
|
||||
if (lastTranscodingUrlRef.current === newTranscodingUrl) {
|
||||
console.log(`[PLAYER] Skipping duplicate transcoding request`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Kill current transcoding process
|
||||
await fetch(`/api/stream/${videoId}/transcode`, { method: 'DELETE' });
|
||||
|
||||
// Wait a moment to ensure cleanup
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Start new transcoding with seek parameter
|
||||
lastTranscodingUrlRef.current = newTranscodingUrl;
|
||||
videoRef.current.src = newTranscodingUrl;
|
||||
videoRef.current.load();
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup transcoding process:', error);
|
||||
// Try fallback direct seek
|
||||
stableHandleSeek(newTime);
|
||||
}
|
||||
} else {
|
||||
// Direct video seeking
|
||||
stableHandleSeek(newTime);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFullscreen = () => {
|
||||
if (videoRef.current) {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
videoRef.current.requestFullscreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBookmark = () => {
|
||||
if (onBookmark && video && 'id' in video && video.id !== undefined) {
|
||||
onBookmark(video.id);
|
||||
// Update local state immediately
|
||||
setIsBookmarked(true);
|
||||
setBookmarkCount(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnbookmark = () => {
|
||||
if (onUnbookmark && video && 'id' in video && video.id !== undefined) {
|
||||
onUnbookmark(video.id);
|
||||
// Update local state immediately
|
||||
setIsBookmarked(false);
|
||||
setBookmarkCount(prev => Math.max(0, prev - 1));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRate = (rating: number) => {
|
||||
if (onRate && video && 'id' in video && video.id !== undefined) {
|
||||
onRate(video.id, rating);
|
||||
}
|
||||
};
|
||||
|
||||
const getVideoTitle = () => {
|
||||
if (video && 'title' in video) return video.title;
|
||||
if (video && 'name' in video) return video.name;
|
||||
return 'Video';
|
||||
};
|
||||
|
||||
const getVideoSize = () => {
|
||||
if (!video) return '0 Bytes';
|
||||
if (formatFileSize) {
|
||||
return formatFileSize(video.size);
|
||||
}
|
||||
// Default format function
|
||||
const bytes = video.size;
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const getBookmarkCount = () => {
|
||||
if (video && 'bookmark_count' in video) return video.bookmark_count;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const getAvgRating = () => {
|
||||
if (video && 'avg_rating' in video) return video.avg_rating;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const getVideoId = () => {
|
||||
if (video && 'id' in video && video.id !== undefined) return video.id;
|
||||
return 0;
|
||||
};
|
||||
|
||||
|
||||
if (!isOpen || typeof window === 'undefined') return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center">
|
||||
<div className="relative w-full h-full max-w-7xl max-h-[90vh] mx-auto my-8">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 z-10 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 transition-colors"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
{/* Transcoding indicator */}
|
||||
{isTranscoding && !transcodingError && (
|
||||
<div className="absolute top-4 left-4 z-10 bg-yellow-500/20 text-yellow-600 rounded-full px-3 py-1.5 flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm">Transcoding</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error indicator */}
|
||||
{transcodingError && (
|
||||
<div className="absolute top-4 left-4 right-4 z-10 bg-red-500/20 border border-red-500/50 text-red-400 rounded-lg px-4 py-3 flex items-center gap-3">
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full flex-shrink-0"></div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">Playback Error</div>
|
||||
<div className="text-xs opacity-90">{transcodingError}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const videoId = getVideoId();
|
||||
if (videoId && videoRef.current) {
|
||||
setTranscodingError(null);
|
||||
setRetryCount(0);
|
||||
setIsTranscoding(false);
|
||||
videoRef.current.src = `/api/stream/${videoId}`;
|
||||
videoRef.current.load();
|
||||
}
|
||||
}}
|
||||
className="text-red-400 hover:text-red-300 text-xs underline flex-shrink-0"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video container */}
|
||||
<div
|
||||
className="relative w-full h-full bg-black rounded-lg overflow-hidden"
|
||||
onMouseMove={() => setShowControls(true)}
|
||||
onMouseLeave={() => setShowControls(false)}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-contain"
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onMouseMove={() => setShowControls(true)}
|
||||
onMouseLeave={() => setShowControls(false)}
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
|
||||
{/* Title overlay */}
|
||||
<div className={`absolute top-0 left-0 right-0 bg-gradient-to-b from-black/60 to-transparent p-4 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<h2 className="text-white text-lg font-semibold">{getVideoTitle()}</h2>
|
||||
</div>
|
||||
|
||||
{/* Controls overlay */}
|
||||
<div className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`}>
|
||||
{/* Enhanced Progress bar with buffer visualization */}
|
||||
<div className="mb-4">
|
||||
<div className="relative w-full h-2 bg-gray-600 rounded-lg overflow-hidden">
|
||||
{/* Buffer indicator */}
|
||||
{bufferState.buffered > 0 && (
|
||||
<div
|
||||
className="absolute top-0 h-full bg-blue-400/30 rounded-lg"
|
||||
style={{
|
||||
width: `${Math.min((bufferState.buffered / (duration || 1)) * 100, 100)}%`,
|
||||
left: '0'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Progress indicator */}
|
||||
<div
|
||||
className="absolute top-0 h-full bg-blue-500 rounded-lg transition-all duration-100"
|
||||
style={{
|
||||
width: `${Math.min((currentTime / (duration || 1)) * 100, 100)}%`,
|
||||
left: '0'
|
||||
}}
|
||||
/>
|
||||
{/* Seek input overlay */}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 0}
|
||||
value={currentTime}
|
||||
onChange={(e) => handleSeek(parseFloat(e.target.value))}
|
||||
onMouseDown={stableHandleSeekStart}
|
||||
onMouseUp={stableHandleSeekEnd}
|
||||
disabled={isDurationLoading}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-white text-sm mt-1">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
{isDurationLoading ? (
|
||||
<span className="text-gray-400">Loading...</span>
|
||||
) : (
|
||||
<span>{formatTime(duration)}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Buffer status */}
|
||||
{bufferState.buffered > 0 && (
|
||||
<div className="text-xs text-blue-300 mt-1">
|
||||
Buffered: {formatTime(bufferState.buffered)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Video Info Bar (similar to photo viewer) */}
|
||||
<div className="bg-black/70 backdrop-blur-sm rounded-lg p-4 mb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-white font-medium">{getVideoTitle()}</h3>
|
||||
<p className="text-gray-300 text-sm">{getVideoSize()}</p>
|
||||
{duration > 0 && (
|
||||
<p className="text-gray-300 text-sm">Duration: {formatTime(duration)}
|
||||
{isTranscoding && <span className="text-yellow-400 ml-1">(Transcoded)</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{(showBookmarks || showRatings) && (
|
||||
<div className="flex items-center gap-4">
|
||||
{showBookmarks && (
|
||||
<button
|
||||
onClick={isBookmarked ? handleUnbookmark : handleBookmark}
|
||||
className="flex items-center gap-1 text-white hover:text-yellow-400 transition-colors"
|
||||
>
|
||||
<Bookmark className={`h-4 w-4 ${isBookmarked ? 'fill-yellow-400 text-yellow-400' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
{showRatings && (
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((rating) => (
|
||||
<button
|
||||
key={rating}
|
||||
onClick={() => handleRate(rating)}
|
||||
className="text-white hover:text-yellow-400 transition-colors"
|
||||
>
|
||||
<Star className={`h-4 w-4 ${rating <= Math.round(getAvgRating()) ? 'fill-yellow-400 text-yellow-400' : ''}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Control buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handlePlayPause}
|
||||
className="text-white hover:text-gray-300 transition-colors"
|
||||
>
|
||||
{isPlaying ? <Pause className="h-6 w-6" /> : <Play className="h-6 w-6" />}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleMute}
|
||||
className="text-white hover:text-gray-300 transition-colors"
|
||||
>
|
||||
{isMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-20 h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleFullscreen}
|
||||
className="text-white hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<Maximize className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
|
@ -114,7 +114,7 @@ export const keyboardShortcuts = {
|
|||
};
|
||||
|
||||
/**
|
||||
* Custom CSS for ArtPlayer styling
|
||||
* ArtPlayer-only CSS styles - optimized for single player system
|
||||
*/
|
||||
export const artPlayerStyles = `
|
||||
.artplayer-container {
|
||||
|
|
@ -123,6 +123,17 @@ export const artPlayerStyles = `
|
|||
position: relative;
|
||||
}
|
||||
|
||||
/* Ensure crisp video rendering */
|
||||
.artplayer-container video {
|
||||
image-rendering: crisp-edges;
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
image-rendering: optimize-contrast;
|
||||
-ms-interpolation-mode: nearest-neighbor;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Remove shadows from ALL ArtPlayer elements */
|
||||
.artplayer-container * {
|
||||
box-shadow: none !important;
|
||||
|
|
@ -130,11 +141,19 @@ export const artPlayerStyles = `
|
|||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Ensure video element is never blurred */
|
||||
.artplayer-container video,
|
||||
.artplayer-video {
|
||||
backdrop-filter: none !important;
|
||||
filter: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Remove shadows from ArtPlayer controls */
|
||||
.artplayer-controls {
|
||||
box-shadow: none !important;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.7), transparent) !important;
|
||||
backdrop-filter: none !important;
|
||||
backdrop-filter: blur(4px) !important;
|
||||
}
|
||||
|
||||
/* Play button and state controls */
|
||||
|
|
@ -143,8 +162,8 @@ export const artPlayerStyles = `
|
|||
.artplayer-control {
|
||||
box-shadow: none !important;
|
||||
filter: none !important;
|
||||
background: transparent !important;
|
||||
backdrop-filter: none !important;
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
backdrop-filter: blur(4px) !important;
|
||||
}
|
||||
|
||||
.artplayer-control-play:hover,
|
||||
|
|
@ -152,11 +171,11 @@ export const artPlayerStyles = `
|
|||
.artplayer-control:hover {
|
||||
box-shadow: none !important;
|
||||
filter: none !important;
|
||||
background-color: rgba(255, 255, 255, 0.1) !important;
|
||||
backdrop-filter: none !important;
|
||||
background-color: rgba(255, 255, 255, 0.3) !important;
|
||||
backdrop-filter: blur(4px) !important;
|
||||
}
|
||||
|
||||
/* Remove any drop shadows or filters from buttons */
|
||||
/* Remove any drop shadows or filters from SVG icons */
|
||||
.artplayer-control-play svg,
|
||||
.artplayer-state svg,
|
||||
.artplayer-control svg {
|
||||
|
|
@ -166,9 +185,10 @@ export const artPlayerStyles = `
|
|||
|
||||
/* Center play button styling */
|
||||
.artplayer-state {
|
||||
background: rgba(0, 0, 0, 0.3) !important;
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
border-radius: 50% !important;
|
||||
backdrop-filter: none !important;
|
||||
backdrop-filter: blur(4px) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Progress bar and other elements */
|
||||
|
|
|
|||
|
|
@ -249,18 +249,17 @@ export function requiresHLS(format: VideoFormat): boolean {
|
|||
}
|
||||
|
||||
/**
|
||||
* Check if format requires fallback to current system
|
||||
* Check if format requires fallback to transcoding
|
||||
*/
|
||||
export function requiresFallback(format: VideoFormat): boolean {
|
||||
return format.type === 'fallback';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get optimal player type for format
|
||||
* ArtPlayer is now the only player used throughout the application
|
||||
* This function is kept for backward compatibility but always returns 'artplayer'
|
||||
*/
|
||||
export function getOptimalPlayerType(format: VideoFormat): 'artplayer' | 'current' {
|
||||
if (format.supportLevel === 'native' || format.type === 'hls') {
|
||||
return 'artplayer';
|
||||
}
|
||||
return 'current';
|
||||
export function getOptimalPlayerType(format: VideoFormat): 'artplayer' {
|
||||
// Always return ArtPlayer since it's the only player we use now
|
||||
return 'artplayer';
|
||||
}
|
||||
Loading…
Reference in New Issue