nextav/src/components/unified-video-player.tsx

338 lines
11 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { detectVideoFormat, VideoFile } from '@/lib/video-format-detector';
import ArtPlayerWrapper from '@/components/artplayer-wrapper';
import LocalPlayerLauncher from '@/components/local-player-launcher';
interface UnifiedVideoPlayerProps {
video: VideoFile;
isOpen: boolean;
onClose: () => void;
playerType?: 'modal' | 'inline';
useArtPlayer?: boolean;
onProgress?: (time: number) => void;
onBookmark?: (videoId: number) => void;
onUnbookmark?: (videoId: number) => void;
onRate?: (videoId: number, rating: number) => void;
showBookmarks?: boolean;
showRatings?: boolean;
scrollPosition?: number;
formatFileSize?: (bytes: number) => string;
autoplay?: boolean;
}
export default function UnifiedVideoPlayer({
video,
isOpen,
onClose,
playerType = 'modal',
useArtPlayer: forceArtPlayer = true, // Always use ArtPlayer now
onProgress,
onBookmark,
onUnbookmark,
onRate,
showBookmarks = false,
showRatings = false,
scrollPosition,
formatFileSize,
autoplay = true
}: UnifiedVideoPlayerProps) {
const [format, setFormat] = useState<ReturnType<typeof detectVideoFormat> | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isBookmarked, setIsBookmarked] = useState(false);
const [bookmarkCheckLoading, setBookmarkCheckLoading] = useState(true);
const [currentRating, setCurrentRating] = useState(0);
const [ratingCheckLoading, setRatingCheckLoading] = useState(true);
const [transcodeLoading, setTranscodeLoading] = useState(false);
const [transcodeError, setTranscodeError] = useState<string | null>(null);
const [activeTranscodeJobId, setActiveTranscodeJobId] = useState<string | null>(null);
const activeTranscodeJobIdRef = useRef<string | null>(null);
// Keep ref in sync with state
useEffect(() => {
activeTranscodeJobIdRef.current = activeTranscodeJobId;
}, [activeTranscodeJobId]);
const releaseTranscodeJob = useCallback((jobId: string | null) => {
if (!jobId) return;
fetch(`/api/transcode/${jobId}`, { method: 'DELETE', keepalive: true }).catch(error => {
console.warn('[UnifiedVideoPlayer] Failed to release transcode job:', error);
});
}, []);
const resetDetectedFormat = useCallback(() => {
const detectedFormat = detectVideoFormat(video);
setFormat(detectedFormat);
setTranscodeError(null);
setTranscodeLoading(false);
return detectedFormat;
}, [video]);
const handleClose = useCallback(() => {
// Read from ref to get the latest value, not a stale closure
const jobId = activeTranscodeJobIdRef.current;
if (jobId) {
releaseTranscodeJob(jobId);
setActiveTranscodeJobId(null);
}
resetDetectedFormat();
onClose();
}, [onClose, releaseTranscodeJob, resetDetectedFormat]);
// Check current bookmark status and rating when video opens
useEffect(() => {
if (isOpen && video.id) {
checkBookmarkStatus();
checkRatingStatus();
}
}, [isOpen, video.id]);
const checkBookmarkStatus = async () => {
if (!video.id) return;
setBookmarkCheckLoading(true);
try {
const response = await fetch(`/api/bookmarks/${video.id}`);
if (response.ok) {
const data = await response.json();
setIsBookmarked(data.isBookmarked || false);
} else {
setIsBookmarked(false);
}
} catch (error) {
console.error('Error checking bookmark status:', error);
setIsBookmarked(false);
} finally {
setBookmarkCheckLoading(false);
}
};
const checkRatingStatus = async () => {
if (!video.id) return;
setRatingCheckLoading(true);
try {
const response = await fetch(`/api/stars/${video.id}`);
if (response.ok) {
const data = await response.json();
setCurrentRating(data.rating || 0);
} else {
setCurrentRating(0);
}
} catch (error) {
console.error('Error checking rating status:', error);
setCurrentRating(0);
} finally {
setRatingCheckLoading(false);
}
};
// Detect/reset format whenever a video is opened. This avoids reusing a killed HLS job URL
// after closing and reopening the same video.
useEffect(() => {
if (video && isOpen) {
console.log('[UnifiedVideoPlayer] Detecting format for video:', video);
// Read from ref to get latest jobId without needing it in deps
const prevJobId = activeTranscodeJobIdRef.current;
if (prevJobId) {
releaseTranscodeJob(prevJobId);
setActiveTranscodeJobId(null);
}
const detectedFormat = resetDetectedFormat();
console.log('[UnifiedVideoPlayer] Detected format:', detectedFormat);
setIsLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [video, isOpen]);
useEffect(() => {
if (!isOpen || format?.type !== 'local-player' || process.env.NEXT_PUBLIC_ENABLE_LIVE_TRANSCODE !== 'true') {
return;
}
let cancelled = false;
setTranscodeLoading(true);
setTranscodeError(null);
fetch('/api/transcode/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mediaId: video.id }),
})
.then(async response => {
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || 'Failed to start live transcode');
}
if (!cancelled) {
setActiveTranscodeJobId(data.jobId || null);
setFormat({
type: 'hls',
supportLevel: 'hls',
url: data.playlistUrl,
warning: `Live transcoding (${data.profile})`,
qualities: [{ html: data.profile || 'Transcoded', url: data.playlistUrl, default: true }],
});
}
})
.catch(error => {
if (!cancelled) {
console.warn('[UnifiedVideoPlayer] Live transcode unavailable, falling back to local player:', error);
setTranscodeError(error instanceof Error ? error.message : 'Live transcode unavailable');
}
})
.finally(() => {
if (!cancelled) setTranscodeLoading(false);
});
return () => {
cancelled = true;
};
}, [isOpen, format?.type, video.id]);
// When the modal closes while a transcode job is active, kill it.
useEffect(() => {
if (isOpen) return;
const jobId = activeTranscodeJobIdRef.current;
if (!jobId) return;
releaseTranscodeJob(jobId);
setActiveTranscodeJobId(null);
resetDetectedFormat();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
// Handle ArtPlayer errors with recovery
const handleArtPlayerError = useCallback((error: string) => {
console.log('ArtPlayer encountered error:', error);
// Try to recover by using direct streaming if HLS failed
if (format?.type === 'hls') {
console.log('HLS failed, trying direct streaming fallback...');
const directFormat = {
...format,
type: 'direct' as const,
url: `/api/stream/direct/${video.id}`,
supportLevel: 'native' as const
};
setFormat(directFormat);
} else {
console.log('ArtPlayer error with direct streaming, logging only');
// Just log the error, no more fallbacks needed
}
}, [format, video.id]);
// Handle progress updates
const handleProgressUpdate = useCallback((time: number) => {
if (onProgress) {
onProgress(time);
}
}, [onProgress]);
// Handle bookmark toggle
const handleBookmarkToggle = useCallback(async (videoId: number) => {
if (onBookmark) {
await onBookmark(videoId);
setIsBookmarked(true); // Update local state
}
}, [onBookmark]);
// Handle unbookmark
const handleUnbookmark = useCallback(async (videoId: number) => {
if (onUnbookmark) {
await onUnbookmark(videoId);
setIsBookmarked(false); // Update local state
}
}, [onUnbookmark]);
// Handle rating
const handleRatingUpdate = useCallback(async (videoId: number, rating: number) => {
if (onRate) {
await onRate(videoId, rating);
setCurrentRating(rating); // Update local state
}
}, [onRate]);
// Render appropriate player based on format
const renderPlayer = () => {
console.log('[UnifiedVideoPlayer] renderPlayer called with format:', format);
console.log('[UnifiedVideoPlayer] format?.type:', format?.type);
console.log('[UnifiedVideoPlayer] format?.supportLevel:', format?.supportLevel);
// Check if format requires local player
if (format?.type === 'local-player') {
console.log('[UnifiedVideoPlayer] Rendering LocalPlayerLauncher');
return (
<LocalPlayerLauncher
video={video}
format={format}
onClose={handleClose}
onPlayerSelect={(playerId) => {
console.log(`Selected player: ${playerId}`);
}}
formatFileSize={formatFileSize}
onBookmark={handleBookmarkToggle}
onUnbookmark={handleUnbookmark}
onRate={handleRatingUpdate}
showBookmarks={showBookmarks}
showRatings={showRatings}
notice={transcodeError ? `Live transcode unavailable: ${transcodeError}` : undefined}
/>
);
}
// Default to ArtPlayer for supported formats
console.log('[UnifiedVideoPlayer] Rendering ArtPlayerWrapper');
return (
<ArtPlayerWrapper
video={video}
isOpen={isOpen}
onClose={handleClose}
onProgress={handleProgressUpdate}
onBookmark={handleBookmarkToggle}
onUnbookmark={handleUnbookmark}
onRate={handleRatingUpdate}
onError={handleArtPlayerError}
useArtPlayer={true}
isBookmarked={isBookmarked}
bookmarkCount={video.bookmark_count || 0}
avgRating={currentRating}
showBookmarks={showBookmarks}
showRatings={showRatings}
autoplay={autoplay}
formatOverride={format || undefined}
/>
);
};
if (isLoading || bookmarkCheckLoading || ratingCheckLoading || transcodeLoading) {
return (
<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>{transcodeLoading ? 'Preparing live transcode...' : 'Loading ArtPlayer...'}</p>
</div>
</div>
);
}
console.log('[UnifiedVideoPlayer] Main render - format:', format, 'isLoading:', isLoading);
return (
<div className="unified-video-player">
{/* Format 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">
{format.type === 'local-player' ? 'Local Player' : 'ArtPlayer'} - {format.supportLevel}
</div>
)}
{renderPlayer()}
</div>
);
}
// Re-export for external use
export { detectVideoFormat };