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:
tigeren 2025-09-20 17:57:44 +00:00
parent 86f4d47be1
commit 5e45db122e
9 changed files with 110 additions and 1502 deletions

View File

@ -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']
};
}
}; }
};

View File

@ -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

View File

@ -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();
}

View File

@ -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>
);
}

View File

@ -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 };

View File

@ -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
};
}

View File

@ -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
);
}

View File

@ -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 */

View File

@ -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';
}