mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
364 lines
11 KiB
TypeScript
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;
|