NuvioStreaming/src/components/player/VlcVideoPlayer.tsx
2025-10-24 20:47:14 +05:30

364 lines
11 KiB
TypeScript

import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import { View, Dimensions } from 'react-native';
import { logger } from '../../utils/logger';
// Dynamic import to avoid iOS loading Android native module
let LibVlcPlayerViewComponent: any = null;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mod = require('expo-libvlc-player');
LibVlcPlayerViewComponent = mod?.LibVlcPlayerView || null;
} catch {
LibVlcPlayerViewComponent = null;
}
interface VlcVideoPlayerProps {
source: string;
volume: number;
playbackSpeed: number;
zoomScale: number;
resizeMode: 'contain' | 'cover' | 'none';
onLoad: (data: any) => void;
onProgress: (data: any) => void;
onSeek: (data: any) => void;
onEnd: () => void;
onError: (error: any) => void;
onTracksUpdate: (tracks: { audio: any[], subtitle: any[] }) => void;
selectedAudioTrack?: number | null;
selectedSubtitleTrack?: number | null;
restoreTime?: number | null;
forceRemount?: boolean;
key?: string;
}
interface VlcTrack {
id: number;
name: string;
language?: string;
}
export interface VlcPlayerRef {
seek: (timeInSeconds: number) => void;
pause: () => void;
play: () => void;
}
const VlcVideoPlayer = forwardRef<VlcPlayerRef, VlcVideoPlayerProps>(({
source,
volume,
playbackSpeed,
zoomScale,
resizeMode,
onLoad,
onProgress,
onSeek,
onEnd,
onError,
onTracksUpdate,
selectedAudioTrack,
selectedSubtitleTrack,
restoreTime,
forceRemount,
key,
}, ref) => {
const vlcRef = useRef<any>(null);
const [vlcActive, setVlcActive] = useState(true);
const [duration, setDuration] = useState<number>(0);
const [videoAspectRatio, setVideoAspectRatio] = useState<number | null>(null);
// Expose imperative methods to parent component
useImperativeHandle(ref, () => ({
seek: (timeInSeconds: number) => {
if (vlcRef.current && typeof vlcRef.current.seek === 'function') {
const fraction = Math.min(Math.max(timeInSeconds / (duration || 1), 0), 0.999);
vlcRef.current.seek(fraction);
logger.log(`[VLC] Seeked to ${timeInSeconds}s (${fraction.toFixed(3)})`);
}
},
pause: () => {
if (vlcRef.current && typeof vlcRef.current.pause === 'function') {
vlcRef.current.pause();
logger.log('[VLC] Paused');
}
},
play: () => {
if (vlcRef.current && typeof vlcRef.current.play === 'function') {
vlcRef.current.play();
logger.log('[VLC] Played');
}
}
}), [duration]);
// Compute aspect ratio string for VLC
const toVlcRatio = useCallback((w: number, h: number): string => {
const a = Math.max(1, Math.round(w));
const b = Math.max(1, Math.round(h));
const gcd = (x: number, y: number): number => (y === 0 ? x : gcd(y, x % y));
const g = gcd(a, b);
return `${Math.floor(a / g)}:${Math.floor(b / g)}`;
}, []);
const screenDimensions = Dimensions.get('screen');
const vlcAspectRatio = useMemo(() => {
// For VLC, no forced aspect ratio - let it preserve natural aspect
return undefined;
}, [resizeMode, screenDimensions.width, screenDimensions.height, toVlcRatio]);
const clientScale = useMemo(() => {
if (!videoAspectRatio || screenDimensions.width <= 0 || screenDimensions.height <= 0) {
return 1;
}
if (resizeMode === 'cover') {
const screenAR = screenDimensions.width / screenDimensions.height;
return Math.max(screenAR / videoAspectRatio, videoAspectRatio / screenAR);
}
return 1;
}, [resizeMode, videoAspectRatio, screenDimensions.width, screenDimensions.height]);
// VLC options for better playback
const vlcOptions = useMemo(() => {
return [
'--network-caching=2000',
'--clock-jitter=0',
'--http-reconnect',
'--sout-mux-caching=2000'
];
}, []);
// VLC tracks prop
const vlcTracks = useMemo(() => ({
audio: selectedAudioTrack,
video: 0, // Use first video track
subtitle: selectedSubtitleTrack
}), [selectedAudioTrack, selectedSubtitleTrack]);
const handleFirstPlay = useCallback((info: any) => {
try {
logger.log('[VLC] Video loaded, extracting tracks...');
logger.log('[AndroidVideoPlayer][VLC] Video loaded successfully');
// Process VLC tracks using optimized function
if (info?.tracks) {
processVlcTracks(info.tracks);
}
const lenSec = (info?.length ?? 0) / 1000;
const width = info?.width || 0;
const height = info?.height || 0;
setDuration(lenSec);
onLoad({ duration: lenSec, naturalSize: width && height ? { width, height } : undefined });
if (width > 0 && height > 0) {
setVideoAspectRatio(width / height);
}
// Restore playback position after remount (workaround for surface detach)
if (restoreTime !== undefined && restoreTime !== null && restoreTime > 0) {
setTimeout(() => {
if (vlcRef.current && typeof vlcRef.current.seek === 'function') {
const seekPosition = Math.min(restoreTime / lenSec, 0.999); // Convert to fraction
vlcRef.current.seek(seekPosition);
logger.log('[VLC] Seeked to restore position');
}
}, 500); // Small delay to ensure player is ready
}
} catch (e) {
logger.error('[VLC] onFirstPlay error:', e);
logger.warn('[AndroidVideoPlayer][VLC] onFirstPlay parse error', e);
}
}, [onLoad, restoreTime]);
const handlePositionChanged = useCallback((ev: any) => {
const pos = typeof ev?.position === 'number' ? ev.position : 0;
// We need duration to calculate current time, but it's not available here
// The parent component should handle this calculation
onProgress({ position: pos });
}, [onProgress]);
const handlePlaying = useCallback(() => {
setVlcActive(true);
}, []);
const handlePaused = useCallback(() => {
setVlcActive(false);
}, []);
const handleEndReached = useCallback(() => {
onEnd();
}, [onEnd]);
const handleEncounteredError = useCallback((e: any) => {
logger.error('[AndroidVideoPlayer][VLC] Encountered error:', e);
onError(e);
}, [onError]);
const handleBackground = useCallback(() => {
logger.log('[VLC] App went to background');
}, []);
const handleESAdded = useCallback((tracks: any) => {
try {
logger.log('[VLC] ES Added - processing tracks...');
processVlcTracks(tracks);
} catch (e) {
logger.error('[VLC] onESAdded error:', e);
logger.warn('[AndroidVideoPlayer][VLC] onESAdded parse error', e);
}
}, []);
// Format VLC tracks to match RN Video format - raw version
const formatVlcTracks = useCallback((vlcTracks: Array<{id: number, name: string}>): VlcTrack[] => {
if (!Array.isArray(vlcTracks)) return [];
return vlcTracks.map(track => {
// Just extract basic language info if available, but keep the full name
let language = undefined;
let displayName = track.name || `Track ${track.id + 1}`;
// Log the raw track data for debugging
if (__DEV__) {
logger.log(`[VLC] Raw track data:`, { id: track.id, name: track.name });
}
// Only extract language from brackets if present, but keep full name
const languageMatch = track.name?.match(/\[([^\]]+)\]/);
if (languageMatch && languageMatch[1]) {
language = languageMatch[1].trim();
}
return {
id: track.id,
name: displayName, // Show exactly what VLC provides
language: language
};
});
}, []);
// Optimized VLC track processing function with reduced JSON operations
const processVlcTracks = useCallback((tracks: any) => {
if (!tracks) return;
// Log raw VLC tracks data for debugging
if (__DEV__) {
logger.log(`[VLC] Raw tracks data:`, tracks);
}
const { audio = [], subtitle = [] } = tracks;
// Process audio tracks
if (Array.isArray(audio) && audio.length > 0) {
const formattedAudio = formatVlcTracks(audio);
if (__DEV__) {
logger.log(`[VLC] Audio tracks updated:`, formattedAudio.length);
}
}
// Process subtitle tracks
if (Array.isArray(subtitle) && subtitle.length > 0) {
const formattedSubs = formatVlcTracks(subtitle);
if (__DEV__) {
logger.log(`[VLC] Subtitle tracks updated:`, formattedSubs.length);
}
}
// Notify parent of track updates
onTracksUpdate({ audio, subtitle });
}, [formatVlcTracks, onTracksUpdate]);
// Process URL for VLC compatibility
const processUrlForVLC = useCallback((url: string): string => {
if (!url || typeof url !== 'string') {
logger.warn('[AndroidVideoPlayer][VLC] Invalid URL provided:', url);
return url || '';
}
try {
// Check if URL is already properly formatted
const urlObj = new URL(url);
// Handle special characters in the pathname that might cause issues
const pathname = urlObj.pathname;
const search = urlObj.search;
const hash = urlObj.hash;
// Decode and re-encode the pathname to handle double-encoding
const decodedPathname = decodeURIComponent(pathname);
const encodedPathname = encodeURI(decodedPathname);
// Reconstruct the URL
const processedUrl = `${urlObj.protocol}//${urlObj.host}${encodedPathname}${search}${hash}`;
logger.log(`[AndroidVideoPlayer][VLC] URL processed: ${url} -> ${processedUrl}`);
return processedUrl;
} catch (error) {
logger.warn(`[AndroidVideoPlayer][VLC] URL processing failed, using original: ${error}`);
return url;
}
}, []);
const processedSource = useMemo(() => processUrlForVLC(source), [source, processUrlForVLC]);
if (!LibVlcPlayerViewComponent) {
return (
<View style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#000'
}}>
{/* VLC not available fallback */}
</View>
);
}
return (
<View
style={{
position: 'absolute',
top: 0,
left: 0,
width: screenDimensions.width,
height: screenDimensions.height,
overflow: 'hidden'
}}
>
<LibVlcPlayerViewComponent
ref={vlcRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: screenDimensions.width,
height: screenDimensions.height,
transform: [{ scale: clientScale }]
}}
// Force remount when surfaces are recreated
key={key || 'vlc-default'}
source={processedSource}
aspectRatio={vlcAspectRatio}
// Let VLC auto-fit the video to the view to prevent flicker on mode changes
scale={0}
options={vlcOptions}
tracks={vlcTracks}
volume={Math.round(Math.max(0, Math.min(1, volume)) * 100)}
mute={false}
repeat={false}
rate={playbackSpeed}
autoplay={false}
onFirstPlay={handleFirstPlay}
onPositionChanged={handlePositionChanged}
onPlaying={handlePlaying}
onPaused={handlePaused}
onEndReached={handleEndReached}
onEncounteredError={handleEncounteredError}
onBackground={handleBackground}
onESAdded={handleESAdded}
/>
</View>
);
});
VlcVideoPlayer.displayName = 'VlcVideoPlayer';
export default VlcVideoPlayer;