338 lines
11 KiB
TypeScript
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 }; |