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');
|
console.log('❌ ArtPlayer wrapper file not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check unified video player
|
// Check unified video player (ArtPlayer only)
|
||||||
console.log('\n🔄 Checking Unified Video Player:');
|
console.log('\n🔄 Checking Unified Video Player (ArtPlayer Only):');
|
||||||
const unifiedPlayerPath = path.join(process.cwd(), 'src/components/unified-video-player.tsx');
|
const unifiedPlayerPath = path.join(process.cwd(), 'src/components/unified-video-player.tsx');
|
||||||
if (fs.existsSync(unifiedPlayerPath)) {
|
if (fs.existsSync(unifiedPlayerPath)) {
|
||||||
const content = fs.readFileSync(unifiedPlayerPath, 'utf8');
|
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');
|
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'}`);
|
console.log(`${hasFormatDetection ? '✅' : '❌'} Format detection: ${hasFormatDetection ? 'INTEGRATED' : 'MISSING'}`);
|
||||||
} else {
|
} else {
|
||||||
console.log('❌ Unified video player file not found');
|
console.log('❌ Unified video player file not found');
|
||||||
|
|
@ -201,4 +203,5 @@ module.exports = {
|
||||||
recommendations: issues.length > 0 ? issues : ['All systems ready for testing']
|
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
|
// Detect optimal format and player
|
||||||
const format = detectVideoFormat(video);
|
const format = detectVideoFormat(video);
|
||||||
const optimalPlayer = getOptimalPlayerType(format);
|
|
||||||
|
|
||||||
// Get additional video metadata
|
// Get additional video metadata
|
||||||
let codecInfo = {};
|
let codecInfo = {};
|
||||||
|
|
@ -57,24 +56,19 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
||||||
},
|
},
|
||||||
format: format,
|
format: format,
|
||||||
player: {
|
player: {
|
||||||
type: optimalPlayer,
|
type: 'artplayer',
|
||||||
recommended: optimalPlayer,
|
recommended: 'artplayer',
|
||||||
alternatives: optimalPlayer === 'artplayer' ? ['current'] : ['artplayer'],
|
alternatives: [], // No alternatives - ArtPlayer only
|
||||||
features: {
|
features: {
|
||||||
artplayer: {
|
artplayer: {
|
||||||
supported: format.supportLevel === 'native' || format.type === 'hls',
|
supported: true, // ArtPlayer supports all formats now
|
||||||
quality_control: format.type === 'hls',
|
quality_control: format.type === 'hls',
|
||||||
adaptive_streaming: format.type === 'hls',
|
adaptive_streaming: format.type === 'hls',
|
||||||
subtitle_support: true,
|
subtitle_support: true,
|
||||||
pip_support: true,
|
pip_support: true,
|
||||||
playback_rate: true
|
playback_rate: true,
|
||||||
},
|
autoplay: true,
|
||||||
current: {
|
enhanced_controls: true
|
||||||
supported: true,
|
|
||||||
transcoding: format.type === 'fallback',
|
|
||||||
anti_jitter: true,
|
|
||||||
protected_duration: true,
|
|
||||||
seek_optimization: format.type === 'fallback'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -111,9 +105,9 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||||
|
|
||||||
const { playerType, quality, volume, muted } = await request.json();
|
const { playerType, quality, volume, muted } = await request.json();
|
||||||
|
|
||||||
// Validate player type
|
// Validate player type (only artplayer is supported now)
|
||||||
if (playerType && !['artplayer', 'current'].includes(playerType)) {
|
if (playerType && playerType !== 'artplayer') {
|
||||||
return NextResponse.json({ error: 'Invalid player type' }, { status: 400 });
|
return NextResponse.json({ error: 'Only ArtPlayer is supported' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Here you could store user preferences in the database
|
// Here you could store user preferences in the database
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ export default function ArtPlayerWrapper({
|
||||||
if (!useArtPlayer || !isOpen || !containerRef.current) return;
|
if (!useArtPlayer || !isOpen || !containerRef.current) return;
|
||||||
|
|
||||||
// Inject custom styles to remove shadows
|
// Inject custom styles to remove shadows
|
||||||
const styleId = 'artplayer-custom-styles';
|
const styleId = 'artplayer-styles';
|
||||||
if (!document.getElementById(styleId)) {
|
if (!document.getElementById(styleId)) {
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
style.id = styleId;
|
style.id = styleId;
|
||||||
|
|
@ -107,6 +107,12 @@ export default function ArtPlayerWrapper({
|
||||||
muted: false,
|
muted: false,
|
||||||
volume: volume,
|
volume: volume,
|
||||||
|
|
||||||
|
// Video quality settings to prevent blurriness
|
||||||
|
preload: 'metadata',
|
||||||
|
poster: '', // Clear any poster that might interfere
|
||||||
|
airplay: true,
|
||||||
|
loop: false,
|
||||||
|
|
||||||
// UI controls
|
// UI controls
|
||||||
fullscreen: true,
|
fullscreen: true,
|
||||||
fullscreenWeb: true,
|
fullscreenWeb: true,
|
||||||
|
|
@ -489,7 +495,7 @@ export default function ArtPlayerWrapper({
|
||||||
hlsErrorHandlerRef.current = null;
|
hlsErrorHandlerRef.current = null;
|
||||||
}
|
}
|
||||||
// Clean up custom styles
|
// Clean up custom styles
|
||||||
const styleElement = document.getElementById('artplayer-custom-styles');
|
const styleElement = document.getElementById('artplayer-styles');
|
||||||
if (styleElement) {
|
if (styleElement) {
|
||||||
styleElement.remove();
|
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';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
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 ArtPlayerWrapper from '@/components/artplayer-wrapper';
|
||||||
import VideoViewer from '@/components/video-viewer';
|
|
||||||
import InlineVideoPlayer from '@/components/inline-video-player';
|
|
||||||
|
|
||||||
interface UnifiedVideoPlayerProps {
|
interface UnifiedVideoPlayerProps {
|
||||||
video: VideoFile;
|
video: VideoFile;
|
||||||
|
|
@ -28,7 +26,7 @@ export default function UnifiedVideoPlayer({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
playerType = 'modal',
|
playerType = 'modal',
|
||||||
useArtPlayer: forceArtPlayer = false,
|
useArtPlayer: forceArtPlayer = true, // Always use ArtPlayer now
|
||||||
onProgress,
|
onProgress,
|
||||||
onBookmark,
|
onBookmark,
|
||||||
onUnbookmark,
|
onUnbookmark,
|
||||||
|
|
@ -40,34 +38,24 @@ export default function UnifiedVideoPlayer({
|
||||||
autoplay = true
|
autoplay = true
|
||||||
}: UnifiedVideoPlayerProps) {
|
}: UnifiedVideoPlayerProps) {
|
||||||
const [format, setFormat] = useState<ReturnType<typeof detectVideoFormat> | null>(null);
|
const [format, setFormat] = useState<ReturnType<typeof detectVideoFormat> | null>(null);
|
||||||
const [useArtPlayer, setUseArtPlayer] = useState(forceArtPlayer);
|
|
||||||
const [artPlayerError, setArtPlayerError] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// Detect format and determine optimal player on mount
|
// Detect format on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (video) {
|
if (video) {
|
||||||
const detectedFormat = detectVideoFormat(video);
|
const detectedFormat = detectVideoFormat(video);
|
||||||
setFormat(detectedFormat);
|
setFormat(detectedFormat);
|
||||||
|
|
||||||
// Determine optimal player if not forced
|
|
||||||
if (!forceArtPlayer) {
|
|
||||||
const optimalPlayer = getOptimalPlayerType(detectedFormat);
|
|
||||||
setUseArtPlayer(optimalPlayer === 'artplayer');
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [video, forceArtPlayer]);
|
}, [video]);
|
||||||
|
|
||||||
// Handle ArtPlayer errors by falling back to current player
|
// Handle ArtPlayer errors with recovery
|
||||||
const handleArtPlayerError = useCallback(() => {
|
const handleArtPlayerError = useCallback((error: string) => {
|
||||||
console.log('ArtPlayer encountered error, attempting fallback chain...');
|
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') {
|
if (format?.type === 'hls') {
|
||||||
console.log('HLS failed, trying direct streaming fallback...');
|
console.log('HLS failed, trying direct streaming fallback...');
|
||||||
// Try to use direct streaming URL instead of HLS
|
|
||||||
const directFormat = {
|
const directFormat = {
|
||||||
...format,
|
...format,
|
||||||
type: 'direct' as const,
|
type: 'direct' as const,
|
||||||
|
|
@ -75,12 +63,9 @@ export default function UnifiedVideoPlayer({
|
||||||
supportLevel: 'native' as const
|
supportLevel: 'native' as const
|
||||||
};
|
};
|
||||||
setFormat(directFormat);
|
setFormat(directFormat);
|
||||||
// Keep using ArtPlayer with direct URL
|
|
||||||
} else {
|
} else {
|
||||||
// Final fallback: Use current player system
|
console.log('ArtPlayer error with direct streaming, logging only');
|
||||||
console.log('Direct streaming also failed, falling back to current player system');
|
// Just log the error, no more fallbacks needed
|
||||||
setArtPlayerError(true);
|
|
||||||
setUseArtPlayer(false);
|
|
||||||
}
|
}
|
||||||
}, [format, video.id]);
|
}, [format, video.id]);
|
||||||
|
|
||||||
|
|
@ -105,29 +90,9 @@ export default function UnifiedVideoPlayer({
|
||||||
}
|
}
|
||||||
}, [onRate]);
|
}, [onRate]);
|
||||||
|
|
||||||
// Render appropriate player based on configuration
|
// Always render ArtPlayer (no more fallbacks)
|
||||||
const renderPlayer = () => {
|
const renderPlayer = () => {
|
||||||
// If ArtPlayer failed, always use current player
|
// Always use ArtPlayer for both modal and inline modes
|
||||||
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 (
|
return (
|
||||||
<ArtPlayerWrapper
|
<ArtPlayerWrapper
|
||||||
video={video}
|
video={video}
|
||||||
|
|
@ -147,60 +112,6 @@ export default function UnifiedVideoPlayer({
|
||||||
autoplay={autoplay}
|
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];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
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="fixed inset-0 bg-black/90 z-50 flex items-center justify-center">
|
||||||
<div className="text-white text-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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -216,10 +127,10 @@ export default function UnifiedVideoPlayer({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="unified-video-player">
|
<div className="unified-video-player">
|
||||||
{/* Player selection indicator (for debugging) */}
|
{/* ArtPlayer indicator (for debugging) */}
|
||||||
{process.env.NODE_ENV === 'development' && format && (
|
{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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -228,5 +139,5 @@ export default function UnifiedVideoPlayer({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export the hook for external use
|
// Re-export for external use
|
||||||
export { detectVideoFormat, getOptimalPlayerType };
|
export { detectVideoFormat };
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { getFeatureFlags, shouldUseArtPlayer } from '@/lib/feature-flags';
|
|
||||||
import { detectVideoFormat } from '@/lib/video-format-detector';
|
import { detectVideoFormat } from '@/lib/video-format-detector';
|
||||||
|
|
||||||
interface VideoPlayerDebugProps {
|
interface VideoPlayerDebugProps {
|
||||||
|
|
@ -11,25 +10,19 @@ interface VideoPlayerDebugProps {
|
||||||
path: string;
|
path: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
};
|
};
|
||||||
useArtPlayer?: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VideoPlayerDebug({ video, useArtPlayer, className = '' }: VideoPlayerDebugProps) {
|
export default function VideoPlayerDebug({ video, className = '' }: VideoPlayerDebugProps) {
|
||||||
const [debugInfo, setDebugInfo] = useState({
|
const [debugInfo, setDebugInfo] = useState({
|
||||||
playerType: 'unknown',
|
playerType: 'artplayer', // Always ArtPlayer now
|
||||||
format: null as any,
|
format: null as any,
|
||||||
featureFlags: null as any,
|
reason: 'ArtPlayer is the only supported player'
|
||||||
userId: 'test-user',
|
|
||||||
reason: ''
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
|
||||||
// Get feature flags
|
|
||||||
const flags = getFeatureFlags(debugInfo.userId, video.id.toString());
|
|
||||||
|
|
||||||
// Detect video format
|
// Detect video format
|
||||||
const format = detectVideoFormat({
|
const format = detectVideoFormat({
|
||||||
id: video.id,
|
id: video.id,
|
||||||
|
|
@ -40,29 +33,12 @@ export default function VideoPlayerDebug({ video, useArtPlayer, className = '' }
|
||||||
type: video.type || 'video'
|
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({
|
setDebugInfo({
|
||||||
playerType,
|
playerType: 'artplayer',
|
||||||
format,
|
format,
|
||||||
featureFlags: flags,
|
reason: 'ArtPlayer is the only supported player'
|
||||||
userId: debugInfo.userId,
|
|
||||||
reason
|
|
||||||
});
|
});
|
||||||
}, [video, useArtPlayer]);
|
}, [video]);
|
||||||
|
|
||||||
if (!video) return null;
|
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() {
|
export function ArtPlayerTestBanner() {
|
||||||
const [isVisible, setIsVisible] = useState(true);
|
const [isVisible, setIsVisible] = useState(true);
|
||||||
|
|
@ -105,10 +81,10 @@ export function ArtPlayerTestBanner() {
|
||||||
if (!isVisible) return null;
|
if (!isVisible) return null;
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<span className="font-bold">🎬 ArtPlayer Integration Active!</span>
|
<span className="font-bold">🎬 ArtPlayer Only Mode!</span>
|
||||||
<span>- Modern video player with enhanced features</span>
|
<span>- Clean, consistent video experience across all formats</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsVisible(false)}
|
onClick={() => setIsVisible(false)}
|
||||||
className="ml-4 text-white hover:text-gray-200"
|
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) {
|
export function useArtPlayerDebug() {
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
forceArtPlayer,
|
forceArtPlayer: true,
|
||||||
shouldUseArtPlayer: forceArtPlayer || shouldUseArtPlayer(userId, videoId)
|
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 = `
|
export const artPlayerStyles = `
|
||||||
.artplayer-container {
|
.artplayer-container {
|
||||||
|
|
@ -123,6 +123,17 @@ export const artPlayerStyles = `
|
||||||
position: relative;
|
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 */
|
/* Remove shadows from ALL ArtPlayer elements */
|
||||||
.artplayer-container * {
|
.artplayer-container * {
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
|
|
@ -130,11 +141,19 @@ export const artPlayerStyles = `
|
||||||
text-shadow: none !important;
|
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 */
|
/* Remove shadows from ArtPlayer controls */
|
||||||
.artplayer-controls {
|
.artplayer-controls {
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
background: linear-gradient(to top, rgba(0,0,0,0.7), transparent) !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 */
|
/* Play button and state controls */
|
||||||
|
|
@ -143,8 +162,8 @@ export const artPlayerStyles = `
|
||||||
.artplayer-control {
|
.artplayer-control {
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
filter: none !important;
|
filter: none !important;
|
||||||
background: transparent !important;
|
background: rgba(255, 255, 255, 0.2) !important;
|
||||||
backdrop-filter: none !important;
|
backdrop-filter: blur(4px) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artplayer-control-play:hover,
|
.artplayer-control-play:hover,
|
||||||
|
|
@ -152,11 +171,11 @@ export const artPlayerStyles = `
|
||||||
.artplayer-control:hover {
|
.artplayer-control:hover {
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
filter: none !important;
|
filter: none !important;
|
||||||
background-color: rgba(255, 255, 255, 0.1) !important;
|
background-color: rgba(255, 255, 255, 0.3) !important;
|
||||||
backdrop-filter: none !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-control-play svg,
|
||||||
.artplayer-state svg,
|
.artplayer-state svg,
|
||||||
.artplayer-control svg {
|
.artplayer-control svg {
|
||||||
|
|
@ -166,9 +185,10 @@ export const artPlayerStyles = `
|
||||||
|
|
||||||
/* Center play button styling */
|
/* Center play button styling */
|
||||||
.artplayer-state {
|
.artplayer-state {
|
||||||
background: rgba(0, 0, 0, 0.3) !important;
|
background: rgba(255, 255, 255, 0.2) !important;
|
||||||
border-radius: 50% !important;
|
border-radius: 50% !important;
|
||||||
backdrop-filter: none !important;
|
backdrop-filter: blur(4px) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Progress bar and other elements */
|
/* 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 {
|
export function requiresFallback(format: VideoFormat): boolean {
|
||||||
return format.type === 'fallback';
|
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' {
|
export function getOptimalPlayerType(format: VideoFormat): 'artplayer' {
|
||||||
if (format.supportLevel === 'native' || format.type === 'hls') {
|
// Always return ArtPlayer since it's the only player we use now
|
||||||
return 'artplayer';
|
return 'artplayer';
|
||||||
}
|
}
|
||||||
return 'current';
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue