diff --git a/eas.json b/eas.json index 8a480768..afd500a0 100644 --- a/eas.json +++ b/eas.json @@ -12,7 +12,8 @@ "distribution": "internal" }, "production": { - "autoIncrement": true + "autoIncrement": true, + "extends": "apk" }, "release": { "distribution": "store", diff --git a/src/components/loading/MetadataLoadingScreen.tsx b/src/components/loading/MetadataLoadingScreen.tsx new file mode 100644 index 00000000..39388bb8 --- /dev/null +++ b/src/components/loading/MetadataLoadingScreen.tsx @@ -0,0 +1,305 @@ +import React, { useEffect, useRef } from 'react'; +import { + View, + Text, + StyleSheet, + Dimensions, + Animated, + StatusBar, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useTheme } from '../../contexts/ThemeContext'; + +const { width, height } = Dimensions.get('window'); + +interface MetadataLoadingScreenProps { + type?: 'movie' | 'series'; +} + +export const MetadataLoadingScreen: React.FC = ({ + type = 'movie' +}) => { + const { currentTheme } = useTheme(); + + // Animation values + const fadeAnim = useRef(new Animated.Value(0)).current; + const pulseAnim = useRef(new Animated.Value(0.3)).current; + const shimmerAnim = useRef(new Animated.Value(0)).current; + + useEffect(() => { + // Start entrance animation + Animated.timing(fadeAnim, { + toValue: 1, + duration: 800, + useNativeDriver: true, + }).start(); + + // Continuous pulse animation for skeleton elements + const pulseAnimation = Animated.loop( + Animated.sequence([ + Animated.timing(pulseAnim, { + toValue: 1, + duration: 1200, + useNativeDriver: true, + }), + Animated.timing(pulseAnim, { + toValue: 0.3, + duration: 1200, + useNativeDriver: true, + }), + ]) + ); + + // Shimmer effect for skeleton elements + const shimmerAnimation = Animated.loop( + Animated.timing(shimmerAnim, { + toValue: 1, + duration: 1500, + useNativeDriver: true, + }) + ); + + pulseAnimation.start(); + shimmerAnimation.start(); + + return () => { + pulseAnimation.stop(); + shimmerAnimation.stop(); + }; + }, []); + + const shimmerTranslateX = shimmerAnim.interpolate({ + inputRange: [0, 1], + outputRange: [-width, width], + }); + + const SkeletonElement = ({ + width: elementWidth, + height: elementHeight, + borderRadius = 8, + marginBottom = 8, + style = {}, + }: { + width: number | string; + height: number; + borderRadius?: number; + marginBottom?: number; + style?: any; + }) => ( + + + + + + + ); + + return ( + + + + + {/* Hero Skeleton */} + + + + {/* Overlay content on hero */} + + + + {/* Bottom hero content skeleton */} + + + + + + + + + + + + + + + + + {/* Content Section Skeletons */} + + {/* Synopsis skeleton */} + + + + + + + + {/* Cast section skeleton */} + + + + {[1, 2, 3, 4].map((item) => ( + + + + + + ))} + + + + {/* Episodes/Details skeleton based on type */} + {type === 'series' ? ( + + + + {[1, 2, 3].map((item) => ( + + + + + + + + + ))} + + ) : ( + + + + + + + + )} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + flex: 1, + }, + heroSection: { + height: height * 0.6, + position: 'relative', + }, + heroOverlay: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'flex-end', + }, + heroBottomContent: { + position: 'absolute', + bottom: 20, + left: 20, + right: 20, + }, + genresRow: { + flexDirection: 'row', + marginBottom: 16, + }, + buttonsRow: { + flexDirection: 'row', + marginBottom: 8, + }, + contentSection: { + padding: 20, + }, + synopsisSection: { + marginBottom: 32, + }, + castSection: { + marginBottom: 32, + }, + castRow: { + flexDirection: 'row', + marginTop: 16, + }, + castItem: { + alignItems: 'center', + marginRight: 16, + }, + episodesSection: { + marginBottom: 32, + }, + episodeItem: { + flexDirection: 'row', + marginBottom: 16, + alignItems: 'center', + }, + episodeInfo: { + flex: 1, + }, + detailsSection: { + marginBottom: 32, + }, + detailsGrid: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 16, + }, +}); + +export default MetadataLoadingScreen; \ No newline at end of file diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 8436a733..3354fcf5 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -31,14 +31,19 @@ interface HeroSectionProps { heroHeight: Animated.SharedValue; heroOpacity: Animated.SharedValue; heroScale: Animated.SharedValue; + heroRotate: Animated.SharedValue; logoOpacity: Animated.SharedValue; logoScale: Animated.SharedValue; + logoRotate: Animated.SharedValue; genresOpacity: Animated.SharedValue; genresTranslateY: Animated.SharedValue; + genresScale: Animated.SharedValue; buttonsOpacity: Animated.SharedValue; buttonsTranslateY: Animated.SharedValue; + buttonsScale: Animated.SharedValue; watchProgressOpacity: Animated.SharedValue; watchProgressScaleY: Animated.SharedValue; + watchProgressWidth: Animated.SharedValue; watchProgress: { currentTime: number; duration: number; @@ -167,17 +172,19 @@ const ActionButtons = React.memo(({ ); }); -// Memoized WatchProgress Component +// Memoized WatchProgress Component with enhanced animations const WatchProgressDisplay = React.memo(({ watchProgress, type, getEpisodeDetails, - animatedStyle + animatedStyle, + progressBarStyle }: { watchProgress: { currentTime: number; duration: number; lastUpdated: number; episodeId?: string } | null; type: 'movie' | 'series'; getEpisodeDetails: (episodeId: string) => { seasonNumber: string; episodeNumber: string; episodeName: string } | null; animatedStyle: any; + progressBarStyle: any; }) => { const { currentTheme } = useTheme(); if (!watchProgress || watchProgress.duration === 0) { @@ -198,9 +205,10 @@ const WatchProgressDisplay = React.memo(({ return ( - = ({ heroHeight, heroOpacity, heroScale, + heroRotate, logoOpacity, logoScale, + logoRotate, genresOpacity, genresTranslateY, + genresScale, buttonsOpacity, buttonsTranslateY, + buttonsScale, watchProgressOpacity, watchProgressScaleY, + watchProgressWidth, watchProgress, type, getEpisodeDetails, @@ -246,18 +259,45 @@ const HeroSection: React.FC = ({ setLogoLoadError, }) => { const { currentTheme } = useTheme(); - // Animated styles + // Enhanced animated styles with sophisticated micro-animations const heroAnimatedStyle = useAnimatedStyle(() => ({ width: '100%', height: heroHeight.value, backgroundColor: currentTheme.colors.black, - transform: [{ scale: heroScale.value }], + transform: [ + { scale: heroScale.value }, + { + rotateZ: `${interpolate( + heroRotate.value, + [0, 1], + [0, 0.2], + Extrapolate.CLAMP + )}deg` + } + ], opacity: heroOpacity.value, })); const logoAnimatedStyle = useAnimatedStyle(() => ({ opacity: logoOpacity.value, - transform: [{ scale: logoScale.value }] + transform: [ + { + scale: interpolate( + logoScale.value, + [0, 1], + [0.95, 1], + Extrapolate.CLAMP + ) + }, + { + rotateZ: `${interpolate( + logoRotate.value, + [0, 1], + [0, 0.5], + Extrapolate.CLAMP + )}deg` + } + ] })); const watchProgressAnimatedStyle = useAnimatedStyle(() => ({ @@ -267,22 +307,50 @@ const HeroSection: React.FC = ({ translateY: interpolate( watchProgressScaleY.value, [0, 1], - [-8, 0], + [-12, 0], Extrapolate.CLAMP ) }, - { scaleY: watchProgressScaleY.value } + { scaleY: watchProgressScaleY.value }, + { scaleX: interpolate(watchProgressScaleY.value, [0, 1], [0.9, 1]) } + ] + })); + + const watchProgressBarStyle = useAnimatedStyle(() => ({ + width: `${watchProgressWidth.value * 100}%`, + transform: [ + { scaleX: interpolate(watchProgressWidth.value, [0, 1], [0.8, 1]) } ] })); const genresAnimatedStyle = useAnimatedStyle(() => ({ opacity: genresOpacity.value, - transform: [{ translateY: genresTranslateY.value }] + transform: [ + { translateY: genresTranslateY.value }, + { scale: genresScale.value } + ] })); const buttonsAnimatedStyle = useAnimatedStyle(() => ({ opacity: buttonsOpacity.value, - transform: [{ translateY: buttonsTranslateY.value }] + transform: [ + { + translateY: interpolate( + buttonsTranslateY.value, + [0, 20], + [0, 8], + Extrapolate.CLAMP + ) + }, + { + scale: interpolate( + buttonsScale.value, + [0, 1], + [0.98, 1], + Extrapolate.CLAMP + ) + } + ] })); const parallaxImageStyle = useAnimatedStyle(() => ({ @@ -295,7 +363,7 @@ const HeroSection: React.FC = ({ translateY: interpolate( dampedScrollY.value, [0, 100, 300], - [0, -30, -80], + [0, -35, -90], Extrapolate.CLAMP ) }, @@ -303,9 +371,17 @@ const HeroSection: React.FC = ({ scale: interpolate( dampedScrollY.value, [0, 150, 300], - [1.05, 1.03, 1.01], + [1.08, 1.05, 1.02], Extrapolate.CLAMP ) + }, + { + rotateZ: interpolate( + dampedScrollY.value, + [0, 300], + [0, -0.1], + Extrapolate.CLAMP + ) + 'deg' } ], })); @@ -389,6 +465,7 @@ const HeroSection: React.FC = ({ type={type} getEpisodeDetails={getEpisodeDetails} animatedStyle={watchProgressAnimatedStyle} + progressBarStyle={watchProgressBarStyle} /> {/* Genre Tags */} @@ -495,13 +572,14 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - paddingVertical: 10, - borderRadius: 100, - elevation: 4, + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 28, + elevation: 6, shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.3, - shadowRadius: 4, + shadowOffset: { width: 0, height: 3 }, + shadowOpacity: 0.4, + shadowRadius: 6, flex: 1, }, playButton: { @@ -513,19 +591,19 @@ const styles = StyleSheet.create({ borderColor: '#fff', }, iconButton: { - width: 48, - height: 48, - borderRadius: 24, + width: 52, + height: 52, + borderRadius: 26, backgroundColor: 'rgba(255,255,255,0.2)', borderWidth: 2, borderColor: '#fff', alignItems: 'center', justifyContent: 'center', - elevation: 4, + elevation: 6, shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.3, - shadowRadius: 4, + shadowOffset: { width: 0, height: 3 }, + shadowOpacity: 0.4, + shadowRadius: 6, }, playButtonText: { color: '#000', diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx new file mode 100644 index 00000000..418f0d17 --- /dev/null +++ b/src/components/player/VideoPlayer.tsx @@ -0,0 +1,900 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text } from 'react-native'; +import { VLCPlayer } from 'react-native-vlc-media-player'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { RootStackParamList } from '../../navigation/AppNavigator'; +import { PinchGestureHandler, State, PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler'; +import RNImmersiveMode from 'react-native-immersive-mode'; +import * as ScreenOrientation from 'expo-screen-orientation'; +import { storageService } from '../../services/storageService'; +import { logger } from '../../utils/logger'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import { + DEFAULT_SUBTITLE_SIZE, + AudioTrack, + TextTrack, + ResizeModeType, + WyzieSubtitle, + SubtitleCue, + RESUME_PREF_KEY, + RESUME_PREF, + SUBTITLE_SIZE_KEY +} from './utils/playerTypes'; +import { safeDebugLog, parseSRT, DEBUG_MODE, formatTime } from './utils/playerUtils'; +import { styles } from './utils/playerStyles'; +import SubtitleModals from './modals/SubtitleModals'; +import AudioTrackModal from './modals/AudioTrackModal'; +import ResumeOverlay from './modals/ResumeOverlay'; +import PlayerControls from './controls/PlayerControls'; +import CustomSubtitles from './subtitles/CustomSubtitles'; + +const VideoPlayer: React.FC = () => { + const navigation = useNavigation(); + const route = useRoute>(); + + const { + uri, + title = 'Episode Name', + season, + episode, + episodeTitle, + quality, + year, + streamProvider, + id, + type, + episodeId, + imdbId + } = route.params; + + safeDebugLog("Component mounted with props", { + uri, title, season, episode, episodeTitle, quality, year, + streamProvider, id, type, episodeId, imdbId + }); + + const screenData = Dimensions.get('screen'); + const [screenDimensions, setScreenDimensions] = useState(screenData); + + const [paused, setPaused] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [showControls, setShowControls] = useState(true); + const [playbackSpeed, setPlaybackSpeed] = useState(1); + const [audioTracks, setAudioTracks] = useState([]); + const [selectedAudioTrack, setSelectedAudioTrack] = useState(null); + const [textTracks, setTextTracks] = useState([]); + const [selectedTextTrack, setSelectedTextTrack] = useState(-1); + const [resizeMode, setResizeMode] = useState('stretch'); + const [buffered, setBuffered] = useState(0); + const vlcRef = useRef(null); + const [showAudioModal, setShowAudioModal] = useState(false); + const [showSubtitleModal, setShowSubtitleModal] = useState(false); + const [initialPosition, setInitialPosition] = useState(null); + const [progressSaveInterval, setProgressSaveInterval] = useState(null); + const [isInitialSeekComplete, setIsInitialSeekComplete] = useState(false); + const [showResumeOverlay, setShowResumeOverlay] = useState(false); + const [resumePosition, setResumePosition] = useState(null); + const [rememberChoice, setRememberChoice] = useState(false); + const [resumePreference, setResumePreference] = useState(null); + const fadeAnim = useRef(new Animated.Value(1)).current; + const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false); + const openingFadeAnim = useRef(new Animated.Value(0)).current; + const openingScaleAnim = useRef(new Animated.Value(0.8)).current; + const backgroundFadeAnim = useRef(new Animated.Value(1)).current; + const [isBuffering, setIsBuffering] = useState(false); + const [vlcAudioTracks, setVlcAudioTracks] = useState>([]); + const [vlcTextTracks, setVlcTextTracks] = useState>([]); + const [isPlayerReady, setIsPlayerReady] = useState(false); + const progressAnim = useRef(new Animated.Value(0)).current; + const progressBarRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const seekDebounceTimer = useRef(null); + const pendingSeekValue = useRef(null); + const lastSeekTime = useRef(0); + const [isVideoLoaded, setIsVideoLoaded] = useState(false); + const [videoAspectRatio, setVideoAspectRatio] = useState(null); + const [is16by9Content, setIs16by9Content] = useState(false); + const [customVideoStyles, setCustomVideoStyles] = useState({}); + const [zoomScale, setZoomScale] = useState(1); + const [zoomTranslateX, setZoomTranslateX] = useState(0); + const [zoomTranslateY, setZoomTranslateY] = useState(0); + const [lastZoomScale, setLastZoomScale] = useState(1); + const [lastTranslateX, setLastTranslateX] = useState(0); + const [lastTranslateY, setLastTranslateY] = useState(0); + const pinchRef = useRef(null); + const [customSubtitles, setCustomSubtitles] = useState([]); + const [currentSubtitle, setCurrentSubtitle] = useState(''); + const [subtitleSize, setSubtitleSize] = useState(DEFAULT_SUBTITLE_SIZE); + const [useCustomSubtitles, setUseCustomSubtitles] = useState(false); + const [isLoadingSubtitles, setIsLoadingSubtitles] = useState(false); + const [availableSubtitles, setAvailableSubtitles] = useState([]); + const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState(false); + const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState(false); + const isMounted = useRef(true); + + const calculateVideoStyles = (videoWidth: number, videoHeight: number, screenWidth: number, screenHeight: number) => { + return { + position: 'absolute', + top: 0, + left: 0, + width: screenWidth, + height: screenHeight, + backgroundColor: '#000', + }; + }; + + const onPinchGestureEvent = (event: PinchGestureHandlerGestureEvent) => { + const { scale } = event.nativeEvent; + const newScale = Math.max(1, Math.min(lastZoomScale * scale, 1.1)); + setZoomScale(newScale); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Center Zoom: ${newScale.toFixed(2)}x`); + } + }; + + const onPinchHandlerStateChange = (event: PinchGestureHandlerGestureEvent) => { + if (event.nativeEvent.state === State.END) { + setLastZoomScale(zoomScale); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Pinch ended - saved scale: ${zoomScale.toFixed(2)}x`); + } + } + }; + + const resetZoom = () => { + const targetZoom = is16by9Content ? 1.1 : 1; + setZoomScale(targetZoom); + setLastZoomScale(targetZoom); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Zoom reset to ${targetZoom}x (16:9: ${is16by9Content})`); + } + }; + + useEffect(() => { + if (videoAspectRatio && screenDimensions.width > 0 && screenDimensions.height > 0) { + const styles = calculateVideoStyles( + videoAspectRatio * 1000, + 1000, + screenDimensions.width, + screenDimensions.height + ); + setCustomVideoStyles(styles); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Screen dimensions changed, recalculated styles:`, styles); + } + } + }, [screenDimensions, videoAspectRatio]); + + useEffect(() => { + const subscription = Dimensions.addEventListener('change', ({ screen }) => { + setScreenDimensions(screen); + }); + const initializePlayer = () => { + StatusBar.setHidden(true, 'none'); + enableImmersiveMode(); + startOpeningAnimation(); + }; + initializePlayer(); + return () => { + subscription?.remove(); + const unlockOrientation = async () => { + await ScreenOrientation.unlockAsync(); + }; + unlockOrientation(); + disableImmersiveMode(); + }; + }, []); + + const startOpeningAnimation = () => { + // Animation logic here + }; + + const completeOpeningAnimation = () => { + Animated.parallel([ + Animated.timing(openingFadeAnim, { + toValue: 1, + duration: 600, + useNativeDriver: true, + }), + Animated.timing(openingScaleAnim, { + toValue: 1, + duration: 700, + useNativeDriver: true, + }), + Animated.timing(backgroundFadeAnim, { + toValue: 0, + duration: 800, + useNativeDriver: true, + }), + ]).start(() => { + openingScaleAnim.setValue(1); + openingFadeAnim.setValue(1); + setIsOpeningAnimationComplete(true); + setTimeout(() => { + backgroundFadeAnim.setValue(0); + }, 100); + }); + }; + + useEffect(() => { + const loadWatchProgress = async () => { + if (id && type) { + try { + const savedProgress = await storageService.getWatchProgress(id, type, episodeId); + if (savedProgress) { + const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100; + if (progressPercent < 95) { + setResumePosition(savedProgress.currentTime); + const pref = await AsyncStorage.getItem(RESUME_PREF_KEY); + if (pref === RESUME_PREF.ALWAYS_RESUME) { + setInitialPosition(savedProgress.currentTime); + } else if (pref === RESUME_PREF.ALWAYS_START_OVER) { + setInitialPosition(0); + } else { + setShowResumeOverlay(true); + } + } + } + } catch (error) { + logger.error('[VideoPlayer] Error loading watch progress:', error); + } + } + }; + loadWatchProgress(); + }, [id, type, episodeId]); + + const saveWatchProgress = async () => { + if (id && type && currentTime > 0 && duration > 0) { + const progress = { + currentTime, + duration, + lastUpdated: Date.now() + }; + try { + await storageService.setWatchProgress(id, type, progress, episodeId); + } catch (error) { + logger.error('[VideoPlayer] Error saving watch progress:', error); + } + } + }; + + useEffect(() => { + if (id && type && !paused && duration > 0) { + if (progressSaveInterval) { + clearInterval(progressSaveInterval); + } + const interval = setInterval(() => { + saveWatchProgress(); + }, 5000); + setProgressSaveInterval(interval); + return () => { + clearInterval(interval); + setProgressSaveInterval(null); + }; + } + }, [id, type, paused, currentTime, duration]); + + useEffect(() => { + return () => { + if (id && type && duration > 0) { + saveWatchProgress(); + } + }; + }, [id, type, currentTime, duration]); + + const seekToTime = (timeInSeconds: number) => { + if (!isPlayerReady || duration <= 0 || !vlcRef.current) return; + const normalizedPosition = Math.max(0, Math.min(timeInSeconds / duration, 1)); + try { + if (typeof vlcRef.current.setPosition === 'function') { + vlcRef.current.setPosition(normalizedPosition); + } else if (typeof vlcRef.current.seek === 'function') { + vlcRef.current.seek(normalizedPosition); + } else { + logger.error('[VideoPlayer] No seek method available on VLC player'); + } + } catch (error) { + logger.error('[VideoPlayer] Error during seek operation:', error); + } + }; + + const handleProgressBarTouch = (event: any) => { + if (!duration || duration <= 0) return; + const { locationX } = event.nativeEvent; + processProgressTouch(locationX); + }; + + const handleProgressBarDragStart = () => { + setIsDragging(true); + }; + + const handleProgressBarDragMove = (event: any) => { + if (!isDragging || !duration || duration <= 0) return; + const { locationX } = event.nativeEvent; + processProgressTouch(locationX, true); + }; + + const handleProgressBarDragEnd = () => { + setIsDragging(false); + if (pendingSeekValue.current !== null) { + seekToTime(pendingSeekValue.current); + pendingSeekValue.current = null; + } + }; + + const processProgressTouch = (locationX: number, isDragging = false) => { + progressBarRef.current?.measure((x, y, width, height, pageX, pageY) => { + const percentage = Math.max(0, Math.min(locationX / width, 1)); + const seekTime = percentage * duration; + progressAnim.setValue(percentage); + if (isDragging) { + pendingSeekValue.current = seekTime; + setCurrentTime(seekTime); + } else { + seekToTime(seekTime); + } + }); + }; + + const handleProgress = (event: any) => { + if (isDragging) return; + const currentTimeInSeconds = event.currentTime / 1000; + if (Math.abs(currentTimeInSeconds - currentTime) > 0.5) { + safeSetState(() => setCurrentTime(currentTimeInSeconds)); + const progressPercent = duration > 0 ? currentTimeInSeconds / duration : 0; + Animated.timing(progressAnim, { + toValue: progressPercent, + duration: 250, + useNativeDriver: false, + }).start(); + const bufferedTime = event.bufferTime / 1000 || currentTimeInSeconds; + safeSetState(() => setBuffered(bufferedTime)); + } + }; + + const onLoad = (data: any) => { + setDuration(data.duration / 1000); + if (data.videoSize && data.videoSize.width && data.videoSize.height) { + const aspectRatio = data.videoSize.width / data.videoSize.height; + setVideoAspectRatio(aspectRatio); + const is16x9 = Math.abs(aspectRatio - (16/9)) < 0.1; + setIs16by9Content(is16x9); + if (is16x9) { + setZoomScale(1.1); + setLastZoomScale(1.1); + } else { + setZoomScale(1); + setLastZoomScale(1); + } + const styles = calculateVideoStyles( + data.videoSize.width, + data.videoSize.height, + screenDimensions.width, + screenDimensions.height + ); + setCustomVideoStyles(styles); + } else { + setIs16by9Content(true); + setZoomScale(1.1); + setLastZoomScale(1.1); + const defaultStyles = { + position: 'absolute', + top: 0, + left: 0, + width: screenDimensions.width, + height: screenDimensions.height, + }; + setCustomVideoStyles(defaultStyles); + } + setIsPlayerReady(true); + const audioTracksFromLoad = data.audioTracks || []; + const textTracksFromLoad = data.textTracks || []; + setVlcAudioTracks(audioTracksFromLoad); + setVlcTextTracks(textTracksFromLoad); + if (audioTracksFromLoad.length > 1) { + const firstEnabledAudio = audioTracksFromLoad.find((t: any) => t.id !== -1); + if(firstEnabledAudio) { + setSelectedAudioTrack(firstEnabledAudio.id); + } + } else if (audioTracksFromLoad.length > 0) { + setSelectedAudioTrack(audioTracksFromLoad[0].id); + } + if (imdbId && !customSubtitles.length) { + setTimeout(() => { + fetchAvailableSubtitles(imdbId, true); + }, 2000); + } + if (initialPosition !== null && !isInitialSeekComplete) { + setTimeout(() => { + if (vlcRef.current && duration > 0 && isMounted.current) { + seekToTime(initialPosition); + setIsInitialSeekComplete(true); + } + }, 1000); + } + setIsVideoLoaded(true); + completeOpeningAnimation(); + }; + + const skip = (seconds: number) => { + if (vlcRef.current) { + const newTime = Math.max(0, Math.min(currentTime + seconds, duration)); + seekToTime(newTime); + } + }; + + const onAudioTracks = (data: { audioTracks: AudioTrack[] }) => { + setAudioTracks(data.audioTracks || []); + }; + + const onTextTracks = (e: Readonly<{ textTracks: TextTrack[] }>) => { + setTextTracks(e.textTracks || []); + }; + + const cycleAspectRatio = () => { + const newZoom = zoomScale === 1.1 ? 1 : 1.1; + setZoomScale(newZoom); + setZoomTranslateX(0); + setZoomTranslateY(0); + setLastZoomScale(newZoom); + setLastTranslateX(0); + setLastTranslateY(0); + }; + + const enableImmersiveMode = () => { + StatusBar.setHidden(true, 'none'); + if (Platform.OS === 'android') { + try { + RNImmersiveMode.setBarMode('FullSticky'); + RNImmersiveMode.fullLayout(true); + if (NativeModules.StatusBarManager) { + NativeModules.StatusBarManager.setHidden(true); + } + } catch (error) { + console.log('Immersive mode error:', error); + } + } + }; + + const disableImmersiveMode = () => { + StatusBar.setHidden(false); + if (Platform.OS === 'android') { + RNImmersiveMode.setBarMode('Normal'); + RNImmersiveMode.fullLayout(false); + } + }; + + const handleClose = () => { + ScreenOrientation.unlockAsync().then(() => { + disableImmersiveMode(); + navigation.goBack(); + }); + }; + + useEffect(() => { + const loadResumePreference = async () => { + try { + const pref = await AsyncStorage.getItem(RESUME_PREF_KEY); + if (pref) { + setResumePreference(pref); + if (pref === RESUME_PREF.ALWAYS_RESUME && resumePosition !== null) { + setShowResumeOverlay(false); + setInitialPosition(resumePosition); + } else if (pref === RESUME_PREF.ALWAYS_START_OVER) { + setShowResumeOverlay(false); + setInitialPosition(0); + } + } + } catch (error) { + logger.error('[VideoPlayer] Error loading resume preference:', error); + } + }; + loadResumePreference(); + }, [resumePosition]); + + const resetResumePreference = async () => { + try { + await AsyncStorage.removeItem(RESUME_PREF_KEY); + setResumePreference(null); + } catch (error) { + logger.error('[VideoPlayer] Error resetting resume preference:', error); + } + }; + + const handleResume = async () => { + if (resumePosition !== null && vlcRef.current) { + if (rememberChoice) { + try { + await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_RESUME); + } catch (error) { + logger.error('[VideoPlayer] Error saving resume preference:', error); + } + } + setInitialPosition(resumePosition); + setShowResumeOverlay(false); + setTimeout(() => { + if (vlcRef.current) { + seekToTime(resumePosition); + } + }, 500); + } + }; + + const handleStartFromBeginning = async () => { + if (rememberChoice) { + try { + await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_START_OVER); + } catch (error) { + logger.error('[VideoPlayer] Error saving resume preference:', error); + } + } + setShowResumeOverlay(false); + setInitialPosition(0); + if (vlcRef.current) { + seekToTime(0); + setCurrentTime(0); + } + }; + + const toggleControls = () => { + setShowControls(previousState => !previousState); + }; + + useEffect(() => { + Animated.timing(fadeAnim, { + toValue: showControls ? 1 : 0, + duration: 300, + useNativeDriver: true, + }).start(); + }, [showControls]); + + const handleError = (error: any) => { + logger.error('[VideoPlayer] Playback Error:', error); + }; + + const onBuffering = (event: any) => { + setIsBuffering(event.isBuffering); + }; + + const onEnd = () => { + // End logic here + }; + + const selectAudioTrack = (trackId: number) => { + setSelectedAudioTrack(trackId); + }; + + const selectTextTrack = (trackId: number) => { + if (trackId === -999) { + setUseCustomSubtitles(true); + setSelectedTextTrack(-1); + } else { + setUseCustomSubtitles(false); + setSelectedTextTrack(trackId); + } + }; + + const loadSubtitleSize = async () => { + try { + const savedSize = await AsyncStorage.getItem(SUBTITLE_SIZE_KEY); + if (savedSize) { + setSubtitleSize(parseInt(savedSize, 10)); + } + } catch (error) { + logger.error('[VideoPlayer] Error loading subtitle size:', error); + } + }; + + const saveSubtitleSize = async (size: number) => { + try { + await AsyncStorage.setItem(SUBTITLE_SIZE_KEY, size.toString()); + setSubtitleSize(size); + } catch (error) { + logger.error('[VideoPlayer] Error saving subtitle size:', error); + } + }; + + const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = false) => { + const targetImdbId = imdbIdParam || imdbId; + if (!targetImdbId) { + logger.error('[VideoPlayer] No IMDb ID available for subtitle search'); + return; + } + setIsLoadingSubtitleList(true); + try { + let searchUrl = `https://sub.wyzie.ru/search?id=${targetImdbId}&encoding=utf-8&source=all`; + if (season && episode) { + searchUrl += `&season=${season}&episode=${episode}`; + } + const response = await fetch(searchUrl); + const subtitles: WyzieSubtitle[] = await response.json(); + const uniqueSubtitles = subtitles.reduce((acc, current) => { + const exists = acc.find(item => item.language === current.language); + if (!exists) { + acc.push(current); + } + return acc; + }, [] as WyzieSubtitle[]); + uniqueSubtitles.sort((a, b) => a.display.localeCompare(b.display)); + setAvailableSubtitles(uniqueSubtitles); + if (autoSelectEnglish) { + const englishSubtitle = uniqueSubtitles.find(sub => + sub.language.toLowerCase() === 'eng' || + sub.language.toLowerCase() === 'en' || + sub.display.toLowerCase().includes('english') + ); + if (englishSubtitle) { + loadWyzieSubtitle(englishSubtitle); + return; + } + } + if (!autoSelectEnglish) { + setShowSubtitleLanguageModal(true); + } + } catch (error) { + logger.error('[VideoPlayer] Error fetching subtitles from Wyzie API:', error); + } finally { + setIsLoadingSubtitleList(false); + } + }; + + const loadWyzieSubtitle = async (subtitle: WyzieSubtitle) => { + setShowSubtitleLanguageModal(false); + setIsLoadingSubtitles(true); + try { + const response = await fetch(subtitle.url); + const srtContent = await response.text(); + const parsedCues = parseSRT(srtContent); + setCustomSubtitles(parsedCues); + setUseCustomSubtitles(true); + setSelectedTextTrack(-1); + } catch (error) { + logger.error('[VideoPlayer] Error loading Wyzie subtitle:', error); + } finally { + setIsLoadingSubtitles(false); + } + }; + + const togglePlayback = () => { + if (vlcRef.current) { + setPaused(!paused); + } + }; + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + if (seekDebounceTimer.current) { + clearTimeout(seekDebounceTimer.current); + } + }; + }, []); + + const safeSetState = (setter: any) => { + if (isMounted.current) { + setter(); + } + }; + + useEffect(() => { + if (!useCustomSubtitles || customSubtitles.length === 0) { + if (currentSubtitle !== '') { + setCurrentSubtitle(''); + } + return; + } + const currentCue = customSubtitles.find(cue => + currentTime >= cue.start && currentTime <= cue.end + ); + const newSubtitle = currentCue ? currentCue.text : ''; + setCurrentSubtitle(newSubtitle); + }, [currentTime, customSubtitles, useCustomSubtitles]); + + useEffect(() => { + loadSubtitleSize(); + }, []); + + const increaseSubtitleSize = () => { + const newSize = Math.min(subtitleSize + 2, 32); + saveSubtitleSize(newSize); + }; + + const decreaseSubtitleSize = () => { + const newSize = Math.max(subtitleSize - 2, 8); + saveSubtitleSize(newSize); + }; + + return ( + + + + + Loading video... + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default VideoPlayer; \ No newline at end of file diff --git a/src/components/player/controls/PlayerControls.tsx b/src/components/player/controls/PlayerControls.tsx new file mode 100644 index 00000000..cc73fb52 --- /dev/null +++ b/src/components/player/controls/PlayerControls.tsx @@ -0,0 +1,217 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, Animated, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import { styles } from '../utils/playerStyles'; +import { getTrackDisplayName } from '../utils/playerUtils'; + +interface PlayerControlsProps { + showControls: boolean; + fadeAnim: Animated.Value; + paused: boolean; + title: string; + episodeTitle?: string; + season?: number; + episode?: number; + quality?: string; + year?: number; + streamProvider?: string; + currentTime: number; + duration: number; + playbackSpeed: number; + zoomScale: number; + vlcAudioTracks: Array<{id: number, name: string, language?: string}>; + selectedAudioTrack: number | null; + togglePlayback: () => void; + skip: (seconds: number) => void; + handleClose: () => void; + cycleAspectRatio: () => void; + setShowAudioModal: (show: boolean) => void; + setShowSubtitleModal: (show: boolean) => void; + progressBarRef: React.RefObject; + progressAnim: Animated.Value; + handleProgressBarTouch: (event: any) => void; + handleProgressBarDragStart: () => void; + handleProgressBarDragMove: (event: any) => void; + handleProgressBarDragEnd: () => void; + buffered: number; + formatTime: (seconds: number) => string; +} + +export const PlayerControls: React.FC = ({ + showControls, + fadeAnim, + paused, + title, + episodeTitle, + season, + episode, + quality, + year, + streamProvider, + currentTime, + duration, + playbackSpeed, + zoomScale, + vlcAudioTracks, + selectedAudioTrack, + togglePlayback, + skip, + handleClose, + cycleAspectRatio, + setShowAudioModal, + setShowSubtitleModal, + progressBarRef, + progressAnim, + handleProgressBarTouch, + handleProgressBarDragStart, + handleProgressBarDragMove, + handleProgressBarDragEnd, + buffered, + formatTime, +}) => { + return ( + + {/* Progress bar with enhanced touch handling */} + + + + + {/* Buffered Progress */} + + {/* Animated Progress */} + + + + + + {formatTime(currentTime)} + {formatTime(duration)} + + + + {/* Controls Overlay */} + + {/* Top Gradient & Header */} + + + {/* Title Section - Enhanced with metadata */} + + {title} + {/* Show season and episode for series */} + {season && episode && ( + + S{season}E{episode} {episodeTitle && `• ${episodeTitle}`} + + )} + {/* Show year, quality, and provider */} + + {year && {year}} + {quality && {quality}} + {streamProvider && via {streamProvider}} + + + + + + + + + {/* Center Controls (Play/Pause, Skip) */} + + skip(-10)} style={styles.skipButton}> + + 10 + + + + + skip(10)} style={styles.skipButton}> + + 10 + + + + {/* Bottom Gradient */} + + + {/* Bottom Buttons Row */} + + {/* Speed Button */} + + + Speed ({playbackSpeed}x) + + + {/* Fill/Cover Button - Updated to show fill/cover modes */} + + + + {zoomScale === 1.1 ? 'Fill' : 'Cover'} + + + + {/* Audio Button - Updated to use vlcAudioTracks */} + setShowAudioModal(true)} + disabled={vlcAudioTracks.length <= 1} + > + + + {`Audio: ${getTrackDisplayName(vlcAudioTracks.find(t => t.id === selectedAudioTrack) || {id: -1, name: 'Default'})}`} + + + + {/* Subtitle Button - Always available for external subtitle search */} + setShowSubtitleModal(true)} + > + + + Subtitles + + + + + + + + ); +}; + +export default PlayerControls; \ No newline at end of file diff --git a/src/components/player/modals/AudioTrackModal.tsx b/src/components/player/modals/AudioTrackModal.tsx new file mode 100644 index 00000000..d35e4b69 --- /dev/null +++ b/src/components/player/modals/AudioTrackModal.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, ScrollView } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { styles } from '../utils/playerStyles'; +import { getTrackDisplayName } from '../utils/playerUtils'; + +interface AudioTrackModalProps { + showAudioModal: boolean; + setShowAudioModal: (show: boolean) => void; + vlcAudioTracks: Array<{id: number, name: string, language?: string}>; + selectedAudioTrack: number | null; + selectAudioTrack: (trackId: number) => void; +} + +export const AudioTrackModal: React.FC = ({ + showAudioModal, + setShowAudioModal, + vlcAudioTracks, + selectedAudioTrack, + selectAudioTrack, +}) => { + if (!showAudioModal) return null; + + return ( + + + + Audio + setShowAudioModal(false)} + > + + + + + + + {vlcAudioTracks.length > 0 ? vlcAudioTracks.map(track => ( + { + selectAudioTrack(track.id); + setShowAudioModal(false); + }} + > + + + {getTrackDisplayName(track)} + + {(track.name && track.language) && ( + {track.name} + )} + + {selectedAudioTrack === track.id && ( + + + + )} + + )) : ( + + + No audio tracks available + + )} + + + + + ); +}; + +export default AudioTrackModal; \ No newline at end of file diff --git a/src/components/player/modals/ResumeOverlay.tsx b/src/components/player/modals/ResumeOverlay.tsx new file mode 100644 index 00000000..0c1f4e09 --- /dev/null +++ b/src/components/player/modals/ResumeOverlay.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { View, Text, TouchableOpacity } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import { styles } from '../utils/playerStyles'; +import { formatTime } from '../utils/playerUtils'; + +interface ResumeOverlayProps { + showResumeOverlay: boolean; + resumePosition: number | null; + duration: number; + title: string; + season?: number; + episode?: number; + rememberChoice: boolean; + setRememberChoice: (remember: boolean) => void; + resumePreference: string | null; + resetResumePreference: () => void; + handleResume: () => void; + handleStartFromBeginning: () => void; +} + +export const ResumeOverlay: React.FC = ({ + showResumeOverlay, + resumePosition, + duration, + title, + season, + episode, + rememberChoice, + setRememberChoice, + resumePreference, + resetResumePreference, + handleResume, + handleStartFromBeginning, +}) => { + if (!showResumeOverlay || resumePosition === null) return null; + + return ( + + + + + + + + Continue Watching + + {title} + {season && episode && ` • S${season}E${episode}`} + + + + 0 ? (resumePosition / duration) * 100 : 0}%` } + ]} + /> + + + {formatTime(resumePosition)} {duration > 0 ? `/ ${formatTime(duration)}` : ''} + + + + + + {/* Remember choice checkbox */} + setRememberChoice(!rememberChoice)} + activeOpacity={0.7} + > + + + {rememberChoice && } + + Remember my choice + + + {resumePreference && ( + + Reset + + )} + + + + + + Start Over + + + + Resume + + + + + ); +}; + +export default ResumeOverlay; \ No newline at end of file diff --git a/src/components/player/modals/SubtitleModals.tsx b/src/components/player/modals/SubtitleModals.tsx new file mode 100644 index 00000000..582cf714 --- /dev/null +++ b/src/components/player/modals/SubtitleModals.tsx @@ -0,0 +1,281 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, Image } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { styles } from '../utils/playerStyles'; +import { WyzieSubtitle, SubtitleCue } from '../utils/playerTypes'; +import { getTrackDisplayName, formatLanguage } from '../utils/playerUtils'; + +interface SubtitleModalsProps { + showSubtitleModal: boolean; + setShowSubtitleModal: (show: boolean) => void; + showSubtitleLanguageModal: boolean; + setShowSubtitleLanguageModal: (show: boolean) => void; + isLoadingSubtitleList: boolean; + isLoadingSubtitles: boolean; + customSubtitles: SubtitleCue[]; + availableSubtitles: WyzieSubtitle[]; + vlcTextTracks: Array<{id: number, name: string, language?: string}>; + selectedTextTrack: number; + useCustomSubtitles: boolean; + subtitleSize: number; + fetchAvailableSubtitles: () => void; + loadWyzieSubtitle: (subtitle: WyzieSubtitle) => void; + selectTextTrack: (trackId: number) => void; + increaseSubtitleSize: () => void; + decreaseSubtitleSize: () => void; +} + +export const SubtitleModals: React.FC = ({ + showSubtitleModal, + setShowSubtitleModal, + showSubtitleLanguageModal, + setShowSubtitleLanguageModal, + isLoadingSubtitleList, + isLoadingSubtitles, + customSubtitles, + availableSubtitles, + vlcTextTracks, + selectedTextTrack, + useCustomSubtitles, + subtitleSize, + fetchAvailableSubtitles, + loadWyzieSubtitle, + selectTextTrack, + increaseSubtitleSize, + decreaseSubtitleSize, +}) => { + // Render subtitle settings modal + const renderSubtitleModal = () => { + if (!showSubtitleModal) return null; + + return ( + + + + Subtitle Settings + setShowSubtitleModal(false)} + > + + + + + + + + {/* External Subtitles Section - Priority */} + + External Subtitles + High quality subtitles with size control + + {/* Custom subtitles option - show if loaded */} + {customSubtitles.length > 0 ? ( + { + selectTextTrack(-999); + setShowSubtitleModal(false); + }} + > + + + + + Custom Subtitles + + {customSubtitles.length} cues • Size adjustable + + + {useCustomSubtitles && ( + + + + )} + + ) : null} + + {/* Search for external subtitles */} + { + setShowSubtitleModal(false); + fetchAvailableSubtitles(); + }} + disabled={isLoadingSubtitleList} + > + + {isLoadingSubtitleList ? ( + + ) : ( + + )} + + {isLoadingSubtitleList ? 'Searching...' : 'Search Online Subtitles'} + + + + + + {/* Subtitle Size Controls - Only for custom subtitles */} + {useCustomSubtitles && ( + + Size Control + + + + + + {subtitleSize}px + Font Size + + + + + + + )} + + {/* Built-in Subtitles Section */} + + Built-in Subtitles + System default sizing • No customization + + {/* Off option */} + { + selectTextTrack(-1); + setShowSubtitleModal(false); + }} + > + + + + + Disabled + No subtitles + + {(selectedTextTrack === -1 && !useCustomSubtitles) && ( + + + + )} + + + {/* Available built-in subtitle tracks */} + {vlcTextTracks.length > 0 ? vlcTextTracks.map(track => ( + { + selectTextTrack(track.id); + setShowSubtitleModal(false); + }} + > + + + + + + {getTrackDisplayName(track)} + + + Built-in track • System font size + + + {(selectedTextTrack === track.id && !useCustomSubtitles) && ( + + + + )} + + )) : ( + + + No built-in subtitles available + + )} + + + + + + ); + }; + + // Render subtitle language selection modal + const renderSubtitleLanguageModal = () => { + if (!showSubtitleLanguageModal) return null; + + return ( + + + + Select Language + setShowSubtitleLanguageModal(false)} + > + + + + + + + {availableSubtitles.length > 0 ? availableSubtitles.map(subtitle => ( + loadWyzieSubtitle(subtitle)} + disabled={isLoadingSubtitles} + > + + + + + {formatLanguage(subtitle.language)} + + + {subtitle.display} + + + + {isLoadingSubtitles && ( + + )} + + )) : ( + + + + No subtitles found for this content + + + )} + + + + + ); + }; + + return ( + <> + {renderSubtitleModal()} + {renderSubtitleLanguageModal()} + + ); +}; + +export default SubtitleModals; \ No newline at end of file diff --git a/src/components/player/subtitles/CustomSubtitles.tsx b/src/components/player/subtitles/CustomSubtitles.tsx new file mode 100644 index 00000000..66bbedf6 --- /dev/null +++ b/src/components/player/subtitles/CustomSubtitles.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { View, Text } from 'react-native'; +import { styles } from '../utils/playerStyles'; + +interface CustomSubtitlesProps { + useCustomSubtitles: boolean; + currentSubtitle: string; + subtitleSize: number; +} + +export const CustomSubtitles: React.FC = ({ + useCustomSubtitles, + currentSubtitle, + subtitleSize, +}) => { + if (!useCustomSubtitles || !currentSubtitle) return null; + + return ( + + + + {currentSubtitle} + + + + ); +}; + +export default CustomSubtitles; \ No newline at end of file diff --git a/src/components/player/utils/playerStyles.ts b/src/components/player/utils/playerStyles.ts new file mode 100644 index 00000000..561e0d55 --- /dev/null +++ b/src/components/player/utils/playerStyles.ts @@ -0,0 +1,755 @@ +import { StyleSheet } from 'react-native'; + +export const styles = StyleSheet.create({ + container: { + backgroundColor: '#000', + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + margin: 0, + padding: 0, + }, + videoContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + margin: 0, + padding: 0, + }, + video: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + margin: 0, + padding: 0, + }, + controlsContainer: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'space-between', + margin: 0, + padding: 0, + }, + topGradient: { + paddingTop: 20, + paddingHorizontal: 20, + paddingBottom: 10, + }, + bottomGradient: { + paddingBottom: 20, + paddingHorizontal: 20, + paddingTop: 20, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + }, + titleSection: { + flex: 1, + marginRight: 10, + }, + title: { + color: 'white', + fontSize: 18, + fontWeight: 'bold', + }, + episodeInfo: { + color: 'rgba(255, 255, 255, 0.9)', + fontSize: 14, + marginTop: 3, + }, + metadataRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 5, + flexWrap: 'wrap', + }, + metadataText: { + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 12, + marginRight: 8, + }, + qualityBadge: { + backgroundColor: '#E50914', + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + marginRight: 8, + }, + qualityText: { + color: 'white', + fontSize: 10, + fontWeight: 'bold', + }, + providerText: { + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 12, + fontStyle: 'italic', + }, + closeButton: { + padding: 8, + }, + controls: { + position: 'absolute', + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + gap: 40, + left: 0, + right: 0, + top: '50%', + transform: [{ translateY: -30 }], + zIndex: 1000, + }, + playButton: { + justifyContent: 'center', + alignItems: 'center', + padding: 10, + }, + skipButton: { + alignItems: 'center', + justifyContent: 'center', + }, + skipText: { + color: 'white', + fontSize: 12, + marginTop: 2, + }, + bottomControls: { + gap: 12, + }, + sliderContainer: { + position: 'absolute', + bottom: 55, + left: 0, + right: 0, + paddingHorizontal: 20, + zIndex: 1000, + }, + progressTouchArea: { + height: 30, + justifyContent: 'center', + width: '100%', + }, + progressBarContainer: { + height: 4, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + borderRadius: 2, + overflow: 'hidden', + marginHorizontal: 4, + position: 'relative', + }, + bufferProgress: { + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + backgroundColor: 'rgba(255, 255, 255, 0.4)', + }, + progressBarFill: { + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + backgroundColor: '#E50914', + height: '100%', + }, + timeDisplay: { + flexDirection: 'row', + justifyContent: 'space-between', + width: '100%', + paddingHorizontal: 4, + marginTop: 4, + marginBottom: 8, + }, + duration: { + color: 'white', + fontSize: 12, + fontWeight: '500', + }, + bottomButtons: { + flexDirection: 'row', + justifyContent: 'space-around', + alignItems: 'center', + }, + bottomButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 5, + }, + bottomButtonText: { + color: 'white', + fontSize: 12, + }, + modalOverlay: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.7)', + }, + modalContent: { + width: '80%', + maxHeight: '70%', + backgroundColor: '#222', + borderRadius: 10, + overflow: 'hidden', + zIndex: 1000, + elevation: 5, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.8, + shadowRadius: 5, + }, + modalHeader: { + padding: 16, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + borderBottomWidth: 1, + borderBottomColor: '#333', + }, + modalTitle: { + color: 'white', + fontSize: 18, + fontWeight: 'bold', + }, + trackList: { + padding: 10, + }, + trackItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 15, + borderRadius: 5, + marginVertical: 5, + }, + selectedTrackItem: { + backgroundColor: 'rgba(229, 9, 20, 0.2)', + }, + trackLabel: { + color: 'white', + fontSize: 16, + }, + noTracksText: { + color: 'white', + fontSize: 16, + textAlign: 'center', + padding: 20, + }, + fullscreenOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0,0,0,0.85)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 2000, + }, + enhancedModalContainer: { + width: 300, + maxHeight: '70%', + backgroundColor: '#181818', + borderRadius: 8, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.4, + shadowRadius: 10, + elevation: 8, + }, + enhancedModalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#333', + }, + enhancedModalTitle: { + color: 'white', + fontSize: 18, + fontWeight: 'bold', + }, + enhancedCloseButton: { + padding: 4, + }, + trackListScrollContainer: { + maxHeight: 350, + }, + trackListContainer: { + padding: 6, + }, + enhancedTrackItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 10, + marginVertical: 2, + borderRadius: 6, + backgroundColor: '#222', + }, + trackInfoContainer: { + flex: 1, + marginRight: 8, + }, + trackPrimaryText: { + color: 'white', + fontSize: 14, + fontWeight: '500', + }, + trackSecondaryText: { + color: '#aaa', + fontSize: 11, + marginTop: 2, + }, + selectedIndicatorContainer: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: 'rgba(229, 9, 20, 0.15)', + justifyContent: 'center', + alignItems: 'center', + }, + emptyStateContainer: { + alignItems: 'center', + justifyContent: 'center', + padding: 20, + }, + emptyStateText: { + color: '#888', + fontSize: 14, + marginTop: 8, + textAlign: 'center', + }, + resumeOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.7)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 1000, + }, + resumeContainer: { + width: '80%', + maxWidth: 500, + borderRadius: 12, + padding: 20, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 6, + elevation: 8, + }, + resumeContent: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 20, + }, + resumeIconContainer: { + marginRight: 16, + width: 50, + height: 50, + borderRadius: 25, + backgroundColor: 'rgba(229, 9, 20, 0.2)', + justifyContent: 'center', + alignItems: 'center', + }, + resumeTextContainer: { + flex: 1, + }, + resumeTitle: { + color: 'white', + fontSize: 20, + fontWeight: 'bold', + marginBottom: 4, + }, + resumeInfo: { + color: 'rgba(255, 255, 255, 0.9)', + fontSize: 14, + }, + resumeProgressContainer: { + marginTop: 12, + }, + resumeProgressBar: { + height: 4, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + borderRadius: 2, + overflow: 'hidden', + marginBottom: 6, + }, + resumeProgressFill: { + height: '100%', + backgroundColor: '#E50914', + }, + resumeTimeText: { + color: 'rgba(255,255,255,0.7)', + fontSize: 12, + }, + resumeButtons: { + flexDirection: 'row', + justifyContent: 'flex-end', + width: '100%', + gap: 12, + }, + resumeButton: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 6, + backgroundColor: 'rgba(255, 255, 255, 0.15)', + minWidth: 110, + justifyContent: 'center', + }, + buttonIcon: { + marginRight: 6, + }, + resumeButtonText: { + color: 'white', + fontWeight: 'bold', + fontSize: 14, + }, + resumeFromButton: { + backgroundColor: '#E50914', + }, + rememberChoiceContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 16, + paddingHorizontal: 2, + }, + checkboxContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + checkbox: { + width: 18, + height: 18, + borderRadius: 3, + borderWidth: 2, + borderColor: 'rgba(255, 255, 255, 0.5)', + marginRight: 8, + justifyContent: 'center', + alignItems: 'center', + }, + checkboxChecked: { + backgroundColor: '#E50914', + borderColor: '#E50914', + }, + rememberChoiceText: { + color: 'rgba(255, 255, 255, 0.8)', + fontSize: 14, + }, + resetPreferenceButton: { + padding: 4, + }, + resetPreferenceText: { + color: '#E50914', + fontSize: 12, + fontWeight: 'bold', + }, + openingOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0,0,0,0.85)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 2000, + margin: 0, + padding: 0, + }, + openingContent: { + padding: 20, + backgroundColor: 'rgba(0,0,0,0.85)', + borderRadius: 10, + justifyContent: 'center', + alignItems: 'center', + }, + openingText: { + color: 'white', + fontSize: 18, + fontWeight: 'bold', + marginTop: 20, + }, + videoPlayerContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + margin: 0, + padding: 0, + }, + subtitleSizeContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 10, + paddingVertical: 12, + marginBottom: 8, + backgroundColor: 'rgba(255, 255, 255, 0.05)', + borderRadius: 6, + }, + subtitleSizeLabel: { + color: 'white', + fontSize: 14, + fontWeight: 'bold', + }, + subtitleSizeControls: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + sizeButton: { + width: 30, + height: 30, + borderRadius: 15, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + justifyContent: 'center', + alignItems: 'center', + }, + subtitleSizeText: { + color: 'white', + fontSize: 14, + fontWeight: 'bold', + minWidth: 40, + textAlign: 'center', + }, + customSubtitleContainer: { + position: 'absolute', + bottom: 40, // Position above controls and progress bar + left: 20, + right: 20, + alignItems: 'center', + zIndex: 1500, // Higher z-index to appear above other elements + }, + customSubtitleWrapper: { + backgroundColor: 'rgba(0, 0, 0, 0.7)', + padding: 10, + borderRadius: 5, + }, + customSubtitleText: { + color: 'white', + textAlign: 'center', + textShadowColor: 'rgba(0, 0, 0, 0.9)', + textShadowOffset: { width: 2, height: 2 }, + textShadowRadius: 4, + lineHeight: undefined, // Let React Native calculate line height + fontWeight: '500', + }, + loadSubtitlesButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: 12, + marginTop: 8, + borderRadius: 6, + backgroundColor: 'rgba(229, 9, 20, 0.2)', + borderWidth: 1, + borderColor: '#E50914', + }, + loadSubtitlesText: { + color: '#E50914', + fontSize: 14, + fontWeight: 'bold', + marginLeft: 8, + }, + disabledContainer: { + opacity: 0.5, + }, + disabledText: { + color: '#666', + }, + disabledButton: { + backgroundColor: '#666', + }, + noteContainer: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 10, + }, + noteText: { + color: '#aaa', + fontSize: 12, + marginLeft: 5, + }, + subtitleLanguageItem: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + flagIcon: { + width: 24, + height: 18, + marginRight: 12, + borderRadius: 2, + }, + modernModalContainer: { + width: '90%', + maxWidth: 500, + backgroundColor: '#181818', + borderRadius: 10, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.4, + shadowRadius: 10, + elevation: 8, + }, + modernModalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#333', + }, + modernModalTitle: { + color: 'white', + fontSize: 18, + fontWeight: 'bold', + }, + modernCloseButton: { + padding: 4, + }, + modernTrackListScrollContainer: { + maxHeight: 350, + }, + modernTrackListContainer: { + padding: 6, + }, + sectionContainer: { + marginBottom: 20, + }, + sectionTitle: { + color: 'white', + fontSize: 16, + fontWeight: 'bold', + marginBottom: 8, + }, + sectionDescription: { + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 12, + marginBottom: 12, + }, + trackIconContainer: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + justifyContent: 'center', + alignItems: 'center', + }, + modernTrackInfoContainer: { + flex: 1, + marginLeft: 10, + }, + modernTrackPrimaryText: { + color: 'white', + fontSize: 14, + fontWeight: '500', + }, + modernTrackSecondaryText: { + color: '#aaa', + fontSize: 11, + marginTop: 2, + }, + modernSelectedIndicator: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: 'rgba(255, 255, 255, 0.15)', + justifyContent: 'center', + alignItems: 'center', + }, + modernEmptyStateContainer: { + alignItems: 'center', + justifyContent: 'center', + padding: 20, + }, + modernEmptyStateText: { + color: '#888', + fontSize: 14, + marginTop: 8, + textAlign: 'center', + }, + searchSubtitlesButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: 12, + marginTop: 8, + borderRadius: 6, + backgroundColor: 'rgba(229, 9, 20, 0.2)', + borderWidth: 1, + borderColor: '#E50914', + }, + searchButtonContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + searchSubtitlesText: { + color: '#E50914', + fontSize: 14, + fontWeight: 'bold', + marginLeft: 8, + }, + modernSubtitleSizeContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + modernSizeButton: { + width: 30, + height: 30, + borderRadius: 15, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + justifyContent: 'center', + alignItems: 'center', + }, + modernTrackItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 12, + marginVertical: 4, + borderRadius: 8, + backgroundColor: '#222', + }, + modernSelectedTrackItem: { + backgroundColor: 'rgba(76, 175, 80, 0.15)', + borderWidth: 1, + borderColor: 'rgba(76, 175, 80, 0.3)', + }, + sizeDisplayContainer: { + alignItems: 'center', + flex: 1, + marginHorizontal: 20, + }, + modernSubtitleSizeText: { + color: 'white', + fontSize: 16, + fontWeight: 'bold', + }, + sizeLabel: { + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 12, + marginTop: 2, + }, +}); \ No newline at end of file diff --git a/src/components/player/utils/playerTypes.ts b/src/components/player/utils/playerTypes.ts new file mode 100644 index 00000000..3f2c5d82 --- /dev/null +++ b/src/components/player/utils/playerTypes.ts @@ -0,0 +1,88 @@ +// Player constants +export const RESUME_PREF_KEY = '@video_resume_preference'; +export const RESUME_PREF = { + ALWAYS_ASK: 'always_ask', + ALWAYS_RESUME: 'always_resume', + ALWAYS_START_OVER: 'always_start_over' +}; + +export const SUBTITLE_SIZE_KEY = '@subtitle_size_preference'; +export const DEFAULT_SUBTITLE_SIZE = 16; + +// Define the TrackPreferenceType for audio/text tracks +export type TrackPreferenceType = 'system' | 'disabled' | 'title' | 'language' | 'index'; + +// Define the SelectedTrack type for audio/text tracks +export interface SelectedTrack { + type: TrackPreferenceType; + value?: string | number; // value is optional for 'system' and 'disabled' +} + +export interface VideoPlayerProps { + uri: string; + title?: string; + season?: number; + episode?: number; + episodeTitle?: string; + quality?: string; + year?: number; + streamProvider?: string; + id?: string; + type?: string; + episodeId?: string; + imdbId?: string; // Add IMDb ID for subtitle fetching +} + +// Match the react-native-video AudioTrack type +export interface AudioTrack { + index: number; + title?: string; + language?: string; + bitrate?: number; + type?: string; + selected?: boolean; +} + +// Define TextTrack interface based on react-native-video expected structure +export interface TextTrack { + index: number; + title?: string; + language?: string; + type?: string | null; // Adjusting type based on linter error +} + +// Define the possible resize modes - force to stretch for absolute full screen +export type ResizeModeType = 'contain' | 'cover' | 'fill' | 'none' | 'stretch'; +export const resizeModes: ResizeModeType[] = ['stretch']; // Force stretch mode for absolute full screen + +// Add VLC specific interface for their event structure +export interface VlcMediaEvent { + currentTime: number; + duration: number; + bufferTime?: number; + isBuffering?: boolean; + audioTracks?: Array<{id: number, name: string, language?: string}>; + textTracks?: Array<{id: number, name: string, language?: string}>; + selectedAudioTrack?: number; + selectedTextTrack?: number; +} + +export interface SubtitleCue { + start: number; + end: number; + text: string; +} + +// Add interface for Wyzie subtitle API response +export interface WyzieSubtitle { + id: string; + url: string; + flagUrl: string; + format: string; + encoding: string; + media: string; + display: string; + language: string; + isHearingImpaired: boolean; + source: string; +} \ No newline at end of file diff --git a/src/components/player/utils/playerUtils.ts b/src/components/player/utils/playerUtils.ts new file mode 100644 index 00000000..72aeb0ca --- /dev/null +++ b/src/components/player/utils/playerUtils.ts @@ -0,0 +1,219 @@ +import { logger } from '../../../utils/logger'; +import { useEffect } from 'react'; +import { SubtitleCue } from './playerTypes'; + +// Debug flag - set back to false to disable verbose logging +// WARNING: Setting this to true currently causes infinite render loops +// Use selective logging instead if debugging is needed +export const DEBUG_MODE = false; + +// Safer debug function that won't cause render loops +// Call this with any debugging info you need instead of using inline DEBUG_MODE checks +export const safeDebugLog = (message: string, data?: any) => { + // This function only runs once per call site, avoiding render loops + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + if (DEBUG_MODE) { + if (data) { + logger.log(`[VideoPlayer] ${message}`, data); + } else { + logger.log(`[VideoPlayer] ${message}`); + } + } + }, []); // Empty dependency array means this only runs once per mount +}; + +// Add language code to name mapping +export const languageMap: {[key: string]: string} = { + 'en': 'English', + 'eng': 'English', + 'es': 'Spanish', + 'spa': 'Spanish', + 'fr': 'French', + 'fre': 'French', + 'de': 'German', + 'ger': 'German', + 'it': 'Italian', + 'ita': 'Italian', + 'ja': 'Japanese', + 'jpn': 'Japanese', + 'ko': 'Korean', + 'kor': 'Korean', + 'zh': 'Chinese', + 'chi': 'Chinese', + 'ru': 'Russian', + 'rus': 'Russian', + 'pt': 'Portuguese', + 'por': 'Portuguese', + 'hi': 'Hindi', + 'hin': 'Hindi', + 'ar': 'Arabic', + 'ara': 'Arabic', + 'nl': 'Dutch', + 'dut': 'Dutch', + 'sv': 'Swedish', + 'swe': 'Swedish', + 'no': 'Norwegian', + 'nor': 'Norwegian', + 'fi': 'Finnish', + 'fin': 'Finnish', + 'da': 'Danish', + 'dan': 'Danish', + 'pl': 'Polish', + 'pol': 'Polish', + 'tr': 'Turkish', + 'tur': 'Turkish', + 'cs': 'Czech', + 'cze': 'Czech', + 'hu': 'Hungarian', + 'hun': 'Hungarian', + 'el': 'Greek', + 'gre': 'Greek', + 'th': 'Thai', + 'tha': 'Thai', + 'vi': 'Vietnamese', + 'vie': 'Vietnamese', +}; + +// Function to format language code to readable name +export const formatLanguage = (code?: string): string => { + if (!code) return 'Unknown'; + const normalized = code.toLowerCase(); + const languageName = languageMap[normalized] || code.toUpperCase(); + + // If the result is still the uppercased code, it means we couldn't find it in our map. + if (languageName === code.toUpperCase()) { + return `Unknown (${code})`; + } + + return languageName; +}; + +// Helper function to extract a display name from the track's name property +export const getTrackDisplayName = (track: { name?: string, id: number }): string => { + if (!track || !track.name) return `Track ${track.id}`; + + // Try to extract language from name like "Some Info - [English]" + const languageMatch = track.name.match(/\[(.*?)\]/); + if (languageMatch && languageMatch[1]) { + return languageMatch[1]; + } + + // If no language in brackets, or if the name is simple, use the full name + return track.name; +}; + +// Format time function for the player +export const formatTime = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + if (hours > 0) { + return `${hours}:${mins < 10 ? '0' : ''}${mins}:${secs < 10 ? '0' : ''}${secs}`; + } else { + return `${mins}:${secs < 10 ? '0' : ''}${secs}`; + } +}; + +// Enhanced SRT parser function - more robust +export const parseSRT = (srtContent: string): SubtitleCue[] => { + const cues: SubtitleCue[] = []; + + if (!srtContent || srtContent.trim().length === 0) { + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] SRT Parser: Empty content provided`); + } + return cues; + } + + // Normalize line endings and clean up the content + const normalizedContent = srtContent + .replace(/\r\n/g, '\n') // Convert Windows line endings + .replace(/\r/g, '\n') // Convert Mac line endings + .trim(); + + // Split by double newlines, but also handle cases with multiple empty lines + const blocks = normalizedContent.split(/\n\s*\n/).filter(block => block.trim().length > 0); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] SRT Parser: Found ${blocks.length} blocks after normalization`); + logger.log(`[VideoPlayer] SRT Parser: First few characters: "${normalizedContent.substring(0, 300)}"`); + } + + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i].trim(); + const lines = block.split('\n').map(line => line.trim()).filter(line => line.length > 0); + + if (lines.length >= 3) { + // Find the timestamp line (could be line 1 or 2, depending on numbering) + let timeLineIndex = -1; + let timeMatch = null; + + for (let j = 0; j < Math.min(3, lines.length); j++) { + // More flexible time pattern matching + timeMatch = lines[j].match(/(\d{1,2}):(\d{2}):(\d{2})[,.](\d{3})\s*-->\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{3})/); + if (timeMatch) { + timeLineIndex = j; + break; + } + } + + if (timeMatch && timeLineIndex !== -1) { + try { + const startTime = + parseInt(timeMatch[1]) * 3600 + + parseInt(timeMatch[2]) * 60 + + parseInt(timeMatch[3]) + + parseInt(timeMatch[4]) / 1000; + + const endTime = + parseInt(timeMatch[5]) * 3600 + + parseInt(timeMatch[6]) * 60 + + parseInt(timeMatch[7]) + + parseInt(timeMatch[8]) / 1000; + + // Get text lines (everything after the timestamp line) + const textLines = lines.slice(timeLineIndex + 1); + if (textLines.length > 0) { + const text = textLines + .join('\n') + .replace(/<[^>]*>/g, '') // Remove HTML tags + .replace(/\{[^}]*\}/g, '') // Remove subtitle formatting tags like {italic} + .replace(/\\N/g, '\n') // Handle \N newlines + .trim(); + + if (text.length > 0) { + cues.push({ + start: startTime, + end: endTime, + text: text + }); + + if (DEBUG_MODE && (i < 5 || cues.length <= 10)) { + logger.log(`[VideoPlayer] SRT Parser: Cue ${cues.length}: ${startTime.toFixed(3)}s-${endTime.toFixed(3)}s: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`); + } + } + } + } catch (error) { + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] SRT Parser: Error parsing times for block ${i + 1}: ${error}`); + } + } + } else if (DEBUG_MODE) { + logger.log(`[VideoPlayer] SRT Parser: No valid timestamp found in block ${i + 1}. Lines: ${JSON.stringify(lines.slice(0, 3))}`); + } + } else if (DEBUG_MODE && block.length > 0) { + logger.log(`[VideoPlayer] SRT Parser: Block ${i + 1} has insufficient lines (${lines.length}): "${block.substring(0, 100)}"`); + } + } + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] SRT Parser: Successfully parsed ${cues.length} subtitle cues`); + if (cues.length > 0) { + logger.log(`[VideoPlayer] SRT Parser: Time range: ${cues[0].start.toFixed(1)}s to ${cues[cues.length-1].end.toFixed(1)}s`); + } + } + + return cues; +}; \ No newline at end of file diff --git a/src/hooks/useMetadataAnimations.ts b/src/hooks/useMetadataAnimations.ts index 7ef53e62..8b7f12e7 100644 --- a/src/hooks/useMetadataAnimations.ts +++ b/src/hooks/useMetadataAnimations.ts @@ -4,6 +4,8 @@ import { useSharedValue, withTiming, withSpring, + withSequence, + withDelay, Easing, useAnimatedScrollHandler, interpolate, @@ -12,233 +14,344 @@ import { const { width, height } = Dimensions.get('window'); -// Animation constants +// Refined animation configurations const springConfig = { - damping: 20, - mass: 1, - stiffness: 100 + damping: 25, + mass: 0.8, + stiffness: 120, + overshootClamping: false, + restDisplacementThreshold: 0.01, + restSpeedThreshold: 0.01, }; -// Animation timing constants for staggered appearance -const ANIMATION_DELAY_CONSTANTS = { - HERO: 100, - LOGO: 250, - PROGRESS: 350, - GENRES: 400, - BUTTONS: 450, - CONTENT: 500 +const microSpringConfig = { + damping: 20, + mass: 0.5, + stiffness: 150, + overshootClamping: true, + restDisplacementThreshold: 0.001, + restSpeedThreshold: 0.001, +}; + +// Sophisticated easing curves +const easings = { + // Smooth entrance with slight overshoot + entrance: Easing.bezier(0.34, 1.56, 0.64, 1), + // Gentle bounce for micro-interactions + microBounce: Easing.bezier(0.68, -0.55, 0.265, 1.55), + // Smooth exit + exit: Easing.bezier(0.25, 0.46, 0.45, 0.94), + // Natural movement + natural: Easing.bezier(0.25, 0.1, 0.25, 1), + // Subtle emphasis + emphasis: Easing.bezier(0.19, 1, 0.22, 1), +}; + +// Refined timing constants for orchestrated entrance +const TIMING = { + // Quick initial setup + SCREEN_PREP: 50, + // Staggered content appearance + HERO_BASE: 150, + LOGO: 280, + PROGRESS: 380, + GENRES: 450, + BUTTONS: 520, + CONTENT: 650, + // Micro-delays for polish + MICRO_DELAY: 50, }; export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) => { - // Animation values for screen entrance - const screenScale = useSharedValue(0.92); + // Enhanced screen entrance with micro-animations + const screenScale = useSharedValue(0.96); const screenOpacity = useSharedValue(0); + const screenBlur = useSharedValue(5); - // Animation values for hero section + // Refined hero section animations const heroHeight = useSharedValue(height * 0.5); - const heroScale = useSharedValue(1.05); + const heroScale = useSharedValue(1.08); const heroOpacity = useSharedValue(0); - - // Animation values for content - const contentTranslateY = useSharedValue(60); + const heroRotate = useSharedValue(-0.5); - // Animation values for logo + // Enhanced content animations + const contentTranslateY = useSharedValue(40); + const contentScale = useSharedValue(0.98); + + // Sophisticated logo animations const logoOpacity = useSharedValue(0); - const logoScale = useSharedValue(0.9); + const logoScale = useSharedValue(0.85); + const logoRotate = useSharedValue(2); - // Animation values for progress + // Enhanced progress animations const watchProgressOpacity = useSharedValue(0); const watchProgressScaleY = useSharedValue(0); + const watchProgressWidth = useSharedValue(0); - // Animation values for genres + // Refined genre animations const genresOpacity = useSharedValue(0); - const genresTranslateY = useSharedValue(20); + const genresTranslateY = useSharedValue(15); + const genresScale = useSharedValue(0.95); - // Animation values for buttons + // Enhanced button animations const buttonsOpacity = useSharedValue(0); - const buttonsTranslateY = useSharedValue(30); + const buttonsTranslateY = useSharedValue(20); + const buttonsScale = useSharedValue(0.95); - // Scroll values for parallax effect + // Scroll values with enhanced parallax const scrollY = useSharedValue(0); const dampedScrollY = useSharedValue(0); + const velocityY = useSharedValue(0); - // Header animation values + // Sophisticated header animations const headerOpacity = useSharedValue(0); - const headerElementsY = useSharedValue(-10); + const headerElementsY = useSharedValue(-15); const headerElementsOpacity = useSharedValue(0); + const headerBlur = useSharedValue(10); - // Start entrance animation + // Orchestrated entrance animation sequence useEffect(() => { - // Use a timeout to ensure the animations starts after the component is mounted - const animationTimeout = setTimeout(() => { - // 1. First animate the container - screenScale.value = withSpring(1, springConfig); - screenOpacity.value = withSpring(1, springConfig); + const startAnimation = setTimeout(() => { + // Phase 1: Screen preparation with subtle bounce + screenScale.value = withSequence( + withTiming(1.02, { duration: 200, easing: easings.entrance }), + withTiming(1, { duration: 150, easing: easings.natural }) + ); + screenOpacity.value = withTiming(1, { + duration: 300, + easing: easings.emphasis + }); + screenBlur.value = withTiming(0, { + duration: 400, + easing: easings.natural + }); - // 2. Then animate the hero section with a slight delay + // Phase 2: Hero section with parallax feel setTimeout(() => { - heroOpacity.value = withSpring(1, { - damping: 14, - stiffness: 80 + heroOpacity.value = withSequence( + withTiming(0.8, { duration: 200, easing: easings.entrance }), + withTiming(1, { duration: 100, easing: easings.natural }) + ); + heroScale.value = withSequence( + withTiming(1.02, { duration: 300, easing: easings.entrance }), + withTiming(1, { duration: 200, easing: easings.natural }) + ); + heroRotate.value = withTiming(0, { + duration: 500, + easing: easings.emphasis }); - heroScale.value = withSpring(1, { - damping: 18, - stiffness: 100 - }); - }, ANIMATION_DELAY_CONSTANTS.HERO); + }, TIMING.HERO_BASE); - // 3. Then animate the logo + // Phase 3: Logo with micro-bounce setTimeout(() => { - logoOpacity.value = withSpring(1, { - damping: 12, - stiffness: 100 + logoOpacity.value = withTiming(1, { + duration: 300, + easing: easings.entrance }); - logoScale.value = withSpring(1, { - damping: 14, - stiffness: 90 + logoScale.value = withSequence( + withTiming(1.05, { duration: 150, easing: easings.microBounce }), + withTiming(1, { duration: 100, easing: easings.natural }) + ); + logoRotate.value = withTiming(0, { + duration: 300, + easing: easings.emphasis }); - }, ANIMATION_DELAY_CONSTANTS.LOGO); + }, TIMING.LOGO); - // 4. Then animate the watch progress if applicable + // Phase 4: Progress bar with width animation setTimeout(() => { if (watchProgress && watchProgress.duration > 0) { - watchProgressOpacity.value = withSpring(1, { - damping: 14, - stiffness: 100 - }); - watchProgressScaleY.value = withSpring(1, { - damping: 18, - stiffness: 120 + watchProgressOpacity.value = withTiming(1, { + duration: 250, + easing: easings.entrance }); + watchProgressScaleY.value = withSpring(1, microSpringConfig); + watchProgressWidth.value = withDelay( + 100, + withTiming(1, { duration: 600, easing: easings.emphasis }) + ); } - }, ANIMATION_DELAY_CONSTANTS.PROGRESS); + }, TIMING.PROGRESS); - // 5. Then animate the genres + // Phase 5: Genres with staggered scale setTimeout(() => { - genresOpacity.value = withSpring(1, { - damping: 14, - stiffness: 100 + genresOpacity.value = withTiming(1, { + duration: 250, + easing: easings.entrance }); - genresTranslateY.value = withSpring(0, { - damping: 18, - stiffness: 120 - }); - }, ANIMATION_DELAY_CONSTANTS.GENRES); + genresTranslateY.value = withSpring(0, microSpringConfig); + genresScale.value = withSequence( + withTiming(1.02, { duration: 150, easing: easings.microBounce }), + withTiming(1, { duration: 100, easing: easings.natural }) + ); + }, TIMING.GENRES); - // 6. Then animate the buttons + // Phase 6: Buttons with sophisticated bounce setTimeout(() => { - buttonsOpacity.value = withSpring(1, { - damping: 14, - stiffness: 100 + buttonsOpacity.value = withTiming(1, { + duration: 300, + easing: easings.entrance }); - buttonsTranslateY.value = withSpring(0, { - damping: 18, - stiffness: 120 - }); - }, ANIMATION_DELAY_CONSTANTS.BUTTONS); + buttonsTranslateY.value = withSpring(0, springConfig); + buttonsScale.value = withSequence( + withTiming(1.03, { duration: 200, easing: easings.microBounce }), + withTiming(1, { duration: 150, easing: easings.natural }) + ); + }, TIMING.BUTTONS); - // 7. Finally animate the content section + // Phase 7: Content with layered entrance setTimeout(() => { contentTranslateY.value = withSpring(0, { - damping: 25, - mass: 1, - stiffness: 100 + ...springConfig, + damping: 30, + stiffness: 100, }); - }, ANIMATION_DELAY_CONSTANTS.CONTENT); - }, 50); // Small timeout to ensure component is fully mounted + contentScale.value = withSequence( + withTiming(1.01, { duration: 200, easing: easings.entrance }), + withTiming(1, { duration: 150, easing: easings.natural }) + ); + }, TIMING.CONTENT); + }, TIMING.SCREEN_PREP); - return () => clearTimeout(animationTimeout); + return () => clearTimeout(startAnimation); }, []); - // Effect to animate watch progress when it changes + // Enhanced watch progress animation with width effect useEffect(() => { if (watchProgress && watchProgress.duration > 0) { - watchProgressOpacity.value = withSpring(1, { - mass: 0.2, - stiffness: 100, - damping: 14 - }); - watchProgressScaleY.value = withSpring(1, { - mass: 0.3, - stiffness: 120, - damping: 18 + watchProgressOpacity.value = withTiming(1, { + duration: 300, + easing: easings.entrance }); + watchProgressScaleY.value = withSpring(1, microSpringConfig); + watchProgressWidth.value = withDelay( + 150, + withTiming(1, { duration: 800, easing: easings.emphasis }) + ); } else { - watchProgressOpacity.value = withSpring(0, { - mass: 0.2, - stiffness: 100, - damping: 14 + watchProgressOpacity.value = withTiming(0, { + duration: 200, + easing: easings.exit }); - watchProgressScaleY.value = withSpring(0, { - mass: 0.3, - stiffness: 120, - damping: 18 + watchProgressScaleY.value = withTiming(0, { + duration: 200, + easing: easings.exit + }); + watchProgressWidth.value = withTiming(0, { + duration: 150, + easing: easings.exit }); } - }, [watchProgress, watchProgressOpacity, watchProgressScaleY]); + }, [watchProgress, watchProgressOpacity, watchProgressScaleY, watchProgressWidth]); - // Effect to animate logo when it's available + // Enhanced logo animation with micro-interactions const animateLogo = (hasLogo: boolean) => { if (hasLogo) { logoOpacity.value = withTiming(1, { - duration: 500, - easing: Easing.out(Easing.ease) + duration: 400, + easing: easings.entrance }); + logoScale.value = withSequence( + withTiming(1.05, { duration: 200, easing: easings.microBounce }), + withTiming(1, { duration: 150, easing: easings.natural }) + ); } else { logoOpacity.value = withTiming(0, { - duration: 200, - easing: Easing.in(Easing.ease) + duration: 250, + easing: easings.exit + }); + logoScale.value = withTiming(0.9, { + duration: 250, + easing: easings.exit }); } }; - // Scroll handler + // Enhanced scroll handler with velocity tracking const scrollHandler = useAnimatedScrollHandler({ onScroll: (event) => { const rawScrollY = event.contentOffset.y; - scrollY.value = rawScrollY; + const lastScrollY = scrollY.value; - // Apply spring-like damping for smoother transitions + scrollY.value = rawScrollY; + velocityY.value = rawScrollY - lastScrollY; + + // Enhanced damped scroll with velocity-based easing + const dynamicDuration = Math.min(400, Math.max(200, Math.abs(velocityY.value) * 10)); dampedScrollY.value = withTiming(rawScrollY, { - duration: 300, - easing: Easing.bezier(0.16, 1, 0.3, 1), // Custom spring-like curve + duration: dynamicDuration, + easing: easings.natural, }); - // Update header opacity based on scroll position - const headerThreshold = height * 0.5 - safeAreaTop - 70; // Hero height - inset - buffer + // Sophisticated header animation with blur effect + const headerThreshold = height * 0.5 - safeAreaTop - 60; + const progress = Math.min(1, Math.max(0, (rawScrollY - headerThreshold + 50) / 100)); + if (rawScrollY > headerThreshold) { - headerOpacity.value = withTiming(1, { duration: 200 }); - headerElementsY.value = withTiming(0, { duration: 300 }); - headerElementsOpacity.value = withTiming(1, { duration: 450 }); + headerOpacity.value = withTiming(1, { + duration: 300, + easing: easings.entrance + }); + headerElementsY.value = withSpring(0, microSpringConfig); + headerElementsOpacity.value = withTiming(1, { + duration: 400, + easing: easings.emphasis + }); + headerBlur.value = withTiming(0, { + duration: 300, + easing: easings.natural + }); } else { - headerOpacity.value = withTiming(0, { duration: 150 }); - headerElementsY.value = withTiming(-10, { duration: 200 }); - headerElementsOpacity.value = withTiming(0, { duration: 200 }); + headerOpacity.value = withTiming(0, { + duration: 200, + easing: easings.exit + }); + headerElementsY.value = withTiming(-15, { + duration: 200, + easing: easings.exit + }); + headerElementsOpacity.value = withTiming(0, { + duration: 150, + easing: easings.exit + }); + headerBlur.value = withTiming(5, { + duration: 200, + easing: easings.natural + }); } }, }); return { - // Animated values + // Enhanced animated values screenScale, screenOpacity, + screenBlur, heroHeight, heroScale, heroOpacity, + heroRotate, contentTranslateY, + contentScale, logoOpacity, logoScale, + logoRotate, watchProgressOpacity, watchProgressScaleY, + watchProgressWidth, genresOpacity, genresTranslateY, + genresScale, buttonsOpacity, buttonsTranslateY, + buttonsScale, scrollY, dampedScrollY, + velocityY, headerOpacity, headerElementsY, headerElementsOpacity, + headerBlur, // Functions scrollHandler, diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 16d3e89e..2a5b2c19 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -21,7 +21,7 @@ import DiscoverScreen from '../screens/DiscoverScreen'; import LibraryScreen from '../screens/LibraryScreen'; import SettingsScreen from '../screens/SettingsScreen'; import MetadataScreen from '../screens/MetadataScreen'; -import VideoPlayer from '../screens/VideoPlayer'; +import VideoPlayer from '../components/player/VideoPlayer'; import CatalogScreen from '../screens/CatalogScreen'; import AddonsScreen from '../screens/AddonsScreen'; import SearchScreen from '../screens/SearchScreen'; @@ -662,6 +662,15 @@ const MainTabs = () => { const AppNavigator = () => { const { currentTheme } = useTheme(); + // Handle Android-specific optimizations + useEffect(() => { + if (Platform.OS === 'android') { + // Ensure consistent background color for Android + StatusBar.setBackgroundColor('transparent', true); + StatusBar.setTranslucent(true); + } + }, []); + return ( { barStyle="light-content" /> - - - - - - - - - - + { + return { + cardStyle: { + transform: [ + { + translateX: current.progress.interpolate({ + inputRange: [0, 1], + outputRange: [layouts.screen.width, 0], + }), + }, + ], + backgroundColor: currentTheme.colors.darkBackground, + }, + }; + }, + }), }} - /> - - - - - - - - - - - - + > + + + + + + + + + + + + + + + + + + + + + + ); diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index 2cf547e8..d4e5fad6 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState, useEffect } from 'react'; import { View, Text, @@ -24,11 +24,15 @@ import Animated, { useAnimatedStyle, interpolate, Extrapolate, + useSharedValue, + withTiming, + runOnJS, } from 'react-native-reanimated'; import { RouteProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { RootStackParamList } from '../navigation/AppNavigator'; import { useSettings } from '../hooks/useSettings'; +import { MetadataLoadingScreen } from '../components/loading/MetadataLoadingScreen'; // Import our new components and hooks import HeroSection from '../components/metadata/HeroSection'; @@ -54,6 +58,11 @@ const MetadataScreen = () => { // Get safe area insets const { top: safeAreaTop } = useSafeAreaInsets(); + // Add transition state management + const [showContent, setShowContent] = useState(false); + const loadingOpacity = useSharedValue(1); + const contentOpacity = useSharedValue(0); + const { metadata, loading, @@ -91,6 +100,27 @@ const MetadataScreen = () => { const animations = useMetadataAnimations(safeAreaTop, watchProgress); + // Handle smooth transition from loading to content + useEffect(() => { + if (!loading && metadata && !showContent) { + // Delay content appearance slightly to ensure everything is ready + const timer = setTimeout(() => { + setShowContent(true); + + // Animate transition + loadingOpacity.value = withTiming(0, { duration: 300 }); + contentOpacity.value = withTiming(1, { duration: 300 }); + }, 100); + + return () => clearTimeout(timer); + } else if (loading && showContent) { + // Reset states when going back to loading + setShowContent(false); + loadingOpacity.value = 1; + contentOpacity.value = 0; + } + }, [loading, metadata, showContent]); + // Add wrapper for toggleLibrary that includes haptic feedback const handleToggleLibrary = useCallback(() => { // Trigger appropriate haptic feedback based on action @@ -165,45 +195,53 @@ const MetadataScreen = () => { navigation.goBack(); }, [navigation]); - // Animated styles + // Enhanced animated styles with sophisticated effects const containerAnimatedStyle = useAnimatedStyle(() => ({ flex: 1, - transform: [{ scale: animations.screenScale.value }], - opacity: animations.screenOpacity.value + transform: [ + { scale: animations.screenScale.value }, + { rotateZ: `${animations.heroRotate.value}deg` } + ], + opacity: animations.screenOpacity.value, })); const contentAnimatedStyle = useAnimatedStyle(() => ({ - transform: [{ translateY: animations.contentTranslateY.value }], + transform: [ + { translateY: animations.contentTranslateY.value }, + { scale: animations.contentScale.value } + ], opacity: interpolate( animations.contentTranslateY.value, - [60, 0], + [40, 0], [0, 1], Extrapolate.CLAMP ) })); - if (loading) { + // Enhanced loading screen animated style + const loadingAnimatedStyle = useAnimatedStyle(() => ({ + opacity: loadingOpacity.value, + transform: [ + { scale: interpolate(loadingOpacity.value, [1, 0], [1, 0.98]) } + ] + })); + + // Enhanced content animated style for transition + const contentTransitionStyle = useAnimatedStyle(() => ({ + opacity: contentOpacity.value, + transform: [ + { scale: interpolate(contentOpacity.value, [0, 1], [0.98, 1]) }, + { translateY: interpolate(contentOpacity.value, [0, 1], [10, 0]) } + ] + })); + + if (loading || !showContent) { return ( - - + - - - - Loading content... - - - + ); } @@ -263,119 +301,126 @@ const MetadataScreen = () => { } return ( - - - - {/* Floating Header */} - + + - - - {/* Hero Section */} - + {/* Floating Header */} + + /> - {/* Main Content */} - - {/* Metadata Details */} - + {/* Hero Section */} + imdbId ? ( - - ) : null} + getEpisodeDetails={getEpisodeDetails} + handleShowStreams={handleShowStreams} + handleToggleLibrary={handleToggleLibrary} + inLibrary={inLibrary} + id={id} + navigation={navigation} + getPlayButtonText={getPlayButtonText} + setBannerImage={setBannerImage} + setLogoLoadError={setLogoLoadError} /> - {/* Cast Section */} - - - {/* More Like This Section - Only for movies */} - {type === 'movie' && ( - - )} - - {/* Type-specific content */} - {type === 'series' ? ( - + {/* Metadata Details */} + imdbId ? ( + + ) : null} /> - ) : ( - - )} - - - - + + {/* Cast Section */} + + + {/* More Like This Section - Only for movies */} + {type === 'movie' && ( + + )} + + {/* Type-specific content */} + {type === 'series' ? ( + + ) : ( + + )} + + + + + ); }; diff --git a/src/screens/VideoPlayer.tsx b/src/screens/VideoPlayer.tsx deleted file mode 100644 index df7c9685..00000000 --- a/src/screens/VideoPlayer.tsx +++ /dev/null @@ -1,2939 +0,0 @@ -import React, { useState, useRef, useEffect } from 'react'; -import { View, TouchableOpacity, StyleSheet, Text, Dimensions, Modal, Pressable, StatusBar, Platform, ScrollView, Animated, ActivityIndicator, Image } from 'react-native'; -import { VLCPlayer } from 'react-native-vlc-media-player'; -import { Ionicons } from '@expo/vector-icons'; -import { LinearGradient } from 'expo-linear-gradient'; -import { useSharedValue, runOnJS, withTiming } from 'react-native-reanimated'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { RootStackParamList } from '../navigation/AppNavigator'; -// Add Gesture Handler imports for pinch zoom -import { PinchGestureHandler, State, PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler'; -// Import for navigation bar hiding -import { NativeModules } from 'react-native'; -// Import immersive mode package -import RNImmersiveMode from 'react-native-immersive-mode'; -// Import screen orientation lock -import * as ScreenOrientation from 'expo-screen-orientation'; -// Import storage service for progress tracking -import { storageService } from '../services/storageService'; -import { logger } from '../utils/logger'; -import AsyncStorage from '@react-native-async-storage/async-storage'; - -// Debug flag - set back to false to disable verbose logging -// WARNING: Setting this to true currently causes infinite render loops -// Use selective logging instead if debugging is needed -const DEBUG_MODE = true; - -// Safer debug function that won't cause render loops -// Call this with any debugging info you need instead of using inline DEBUG_MODE checks -const safeDebugLog = (message: string, data?: any) => { - // This function only runs once per call site, avoiding render loops - // eslint-disable-next-line react-hooks/rules-of-hooks - useEffect(() => { - if (DEBUG_MODE) { - if (data) { - logger.log(`[VideoPlayer] ${message}`, data); - } else { - logger.log(`[VideoPlayer] ${message}`); - } - } - }, []); // Empty dependency array means this only runs once per mount -}; - -// Constants for resume preferences - add after type definitions -const RESUME_PREF_KEY = '@video_resume_preference'; -const RESUME_PREF = { - ALWAYS_ASK: 'always_ask', - ALWAYS_RESUME: 'always_resume', - ALWAYS_START_OVER: 'always_start_over' -}; - -// Define the TrackPreferenceType for audio/text tracks -type TrackPreferenceType = 'system' | 'disabled' | 'title' | 'language' | 'index'; - -// Define the SelectedTrack type for audio/text tracks -interface SelectedTrack { - type: TrackPreferenceType; - value?: string | number; // value is optional for 'system' and 'disabled' -} - -interface VideoPlayerProps { - uri: string; - title?: string; - season?: number; - episode?: number; - episodeTitle?: string; - quality?: string; - year?: number; - streamProvider?: string; - id?: string; - type?: string; - episodeId?: string; - imdbId?: string; // Add IMDb ID for subtitle fetching -} - -// Match the react-native-video AudioTrack type -interface AudioTrack { - index: number; - title?: string; - language?: string; - bitrate?: number; - type?: string; - selected?: boolean; -} - -// Define TextTrack interface based on react-native-video expected structure -interface TextTrack { - index: number; - title?: string; - language?: string; - type?: string | null; // Adjusting type based on linter error -} - -// Define the possible resize modes - force to stretch for absolute full screen -type ResizeModeType = 'contain' | 'cover' | 'fill' | 'none' | 'stretch'; -const resizeModes: ResizeModeType[] = ['stretch']; // Force stretch mode for absolute full screen - -// Add language code to name mapping -const languageMap: {[key: string]: string} = { - 'en': 'English', - 'eng': 'English', - 'es': 'Spanish', - 'spa': 'Spanish', - 'fr': 'French', - 'fre': 'French', - 'de': 'German', - 'ger': 'German', - 'it': 'Italian', - 'ita': 'Italian', - 'ja': 'Japanese', - 'jpn': 'Japanese', - 'ko': 'Korean', - 'kor': 'Korean', - 'zh': 'Chinese', - 'chi': 'Chinese', - 'ru': 'Russian', - 'rus': 'Russian', - 'pt': 'Portuguese', - 'por': 'Portuguese', - 'hi': 'Hindi', - 'hin': 'Hindi', - 'ar': 'Arabic', - 'ara': 'Arabic', - 'nl': 'Dutch', - 'dut': 'Dutch', - 'sv': 'Swedish', - 'swe': 'Swedish', - 'no': 'Norwegian', - 'nor': 'Norwegian', - 'fi': 'Finnish', - 'fin': 'Finnish', - 'da': 'Danish', - 'dan': 'Danish', - 'pl': 'Polish', - 'pol': 'Polish', - 'tr': 'Turkish', - 'tur': 'Turkish', - 'cs': 'Czech', - 'cze': 'Czech', - 'hu': 'Hungarian', - 'hun': 'Hungarian', - 'el': 'Greek', - 'gre': 'Greek', - 'th': 'Thai', - 'tha': 'Thai', - 'vi': 'Vietnamese', - 'vie': 'Vietnamese', -}; - -// Function to format language code to readable name -const formatLanguage = (code?: string): string => { - if (!code) return 'Unknown'; - const normalized = code.toLowerCase(); - const languageName = languageMap[normalized] || code.toUpperCase(); - - // Debug logs removed to prevent render loops - - // If the result is still the uppercased code, it means we couldn't find it in our map. - if (languageName === code.toUpperCase()) { - return `Unknown (${code})`; - } - - return languageName; -}; - -// Add VLC specific interface for their event structure -interface VlcMediaEvent { - currentTime: number; - duration: number; - bufferTime?: number; - isBuffering?: boolean; - audioTracks?: Array<{id: number, name: string, language?: string}>; - textTracks?: Array<{id: number, name: string, language?: string}>; - selectedAudioTrack?: number; - selectedTextTrack?: number; -} - -// Helper function to extract a display name from the track's name property -const getTrackDisplayName = (track: { name?: string, id: number }): string => { - if (!track || !track.name) return `Track ${track.id}`; - - // Try to extract language from name like "Some Info - [English]" - const languageMatch = track.name.match(/\[(.*?)\]/); - if (languageMatch && languageMatch[1]) { - return languageMatch[1]; - } - - // If no language in brackets, or if the name is simple, use the full name - return track.name; -}; - -// Add subtitle-related constants and types -const SUBTITLE_SIZE_KEY = '@subtitle_size_preference'; -const DEFAULT_SUBTITLE_SIZE = 16; - -interface SubtitleCue { - start: number; - end: number; - text: string; -} - -// Add interface for Wyzie subtitle API response -interface WyzieSubtitle { - id: string; - url: string; - flagUrl: string; - format: string; - encoding: string; - media: string; - display: string; - language: string; - isHearingImpaired: boolean; - source: string; -} - -const VideoPlayer: React.FC = () => { - const navigation = useNavigation(); - const route = useRoute>(); - - // Extract props from route.params - const { - uri, - title = 'Episode Name', - season, - episode, - episodeTitle, - quality, - year, - streamProvider, - id, - type, - episodeId, - imdbId - } = route.params; - - // Use safer debug logging for props - safeDebugLog("Component mounted with props", { - uri, title, season, episode, episodeTitle, quality, year, - streamProvider, id, type, episodeId, imdbId - }); - - // Get exact screen dimensions - const screenData = Dimensions.get('screen'); // Use 'screen' instead of 'window' to include system UI areas - const [screenDimensions, setScreenDimensions] = useState(screenData); - - const [paused, setPaused] = useState(false); - const [currentTime, setCurrentTime] = useState(0); - const [duration, setDuration] = useState(0); - const [showControls, setShowControls] = useState(true); - const [playbackSpeed, setPlaybackSpeed] = useState(1); - const [audioTracks, setAudioTracks] = useState([]); - const [selectedAudioTrack, setSelectedAudioTrack] = useState(null); - const [textTracks, setTextTracks] = useState([]); - const [selectedTextTrack, setSelectedTextTrack] = useState(-1); // Use -1 for "disabled" - const [resizeMode, setResizeMode] = useState('stretch'); // Force stretch mode for absolute full screen - const [buffered, setBuffered] = useState(0); // Add buffered state - const vlcRef = useRef(null); - const progress = useSharedValue(0); - const min = useSharedValue(0); - const max = useSharedValue(duration); - const [showAudioModal, setShowAudioModal] = useState(false); - const [showSubtitleModal, setShowSubtitleModal] = useState(false); - - // Add state for tracking initial position to seek to - const [initialPosition, setInitialPosition] = useState(null); - const [progressSaveInterval, setProgressSaveInterval] = useState(null); - const [isInitialSeekComplete, setIsInitialSeekComplete] = useState(false); - - // Add state for showing resume overlay - const [showResumeOverlay, setShowResumeOverlay] = useState(false); - const [resumePosition, setResumePosition] = useState(null); - - // Add state for remembering choice - const [rememberChoice, setRememberChoice] = useState(false); - const [resumePreference, setResumePreference] = useState(null); - - // Add animated value for controls opacity - const fadeAnim = useRef(new Animated.Value(1)).current; - - // Add opening animation states and values - const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false); - const openingFadeAnim = useRef(new Animated.Value(0)).current; - const openingScaleAnim = useRef(new Animated.Value(0.8)).current; - const backgroundFadeAnim = useRef(new Animated.Value(1)).current; - - // Add VLC specific state and refs - const [isBuffering, setIsBuffering] = useState(false); - - // Modify audio tracks handling for VLC - const [vlcAudioTracks, setVlcAudioTracks] = useState>([]); - const [vlcTextTracks, setVlcTextTracks] = useState>([]); - - // Add a new state to track if the player is ready for seeking - const [isPlayerReady, setIsPlayerReady] = useState(false); - - // Animated value for smooth progress bar - const progressAnim = useRef(new Animated.Value(0)).current; - - // Add ref for progress bar container to measure its width - const progressBarRef = useRef(null); - - // Add state for progress bar touch tracking - const [isDragging, setIsDragging] = useState(false); - - // Add a ref for debouncing seek operations - const seekDebounceTimer = useRef(null); - const pendingSeekValue = useRef(null); - const lastSeekTime = useRef(0); - - // Add state for tracking if the video is loaded - const [isVideoLoaded, setIsVideoLoaded] = useState(false); - - // Add state for tracking video aspect ratio - const [videoAspectRatio, setVideoAspectRatio] = useState(null); - const [is16by9Content, setIs16by9Content] = useState(false); - const [customVideoStyles, setCustomVideoStyles] = useState({}); - - // Add zoom state for pinch gesture - const [zoomScale, setZoomScale] = useState(1); - const [zoomTranslateX, setZoomTranslateX] = useState(0); - const [zoomTranslateY, setZoomTranslateY] = useState(0); - const [lastZoomScale, setLastZoomScale] = useState(1); - const [lastTranslateX, setLastTranslateX] = useState(0); - const [lastTranslateY, setLastTranslateY] = useState(0); - const pinchRef = useRef(null); - - // Add subtitle-related state - const [customSubtitles, setCustomSubtitles] = useState([]); - const [currentSubtitle, setCurrentSubtitle] = useState(''); - const [subtitleSize, setSubtitleSize] = useState(DEFAULT_SUBTITLE_SIZE); - const [useCustomSubtitles, setUseCustomSubtitles] = useState(false); - const [isLoadingSubtitles, setIsLoadingSubtitles] = useState(false); - - // Add Wyzie subtitle states - const [availableSubtitles, setAvailableSubtitles] = useState([]); - const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState(false); - const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState(false); - - // Calculate custom video styles based on aspect ratios - simplified approach - const calculateVideoStyles = (videoWidth: number, videoHeight: number, screenWidth: number, screenHeight: number) => { - // Always return full screen styles - let VLC resize modes handle the rest - return { - position: 'absolute', - top: 0, - left: 0, - width: screenWidth, - height: screenHeight, - backgroundColor: '#000', - }; - }; - - // Pinch gesture handler for zoom functionality - center zoom only, no panning - const onPinchGestureEvent = (event: PinchGestureHandlerGestureEvent) => { - const { scale } = event.nativeEvent; - - // Calculate new scale (limit between 1x and 1.1x) - const newScale = Math.max(1, Math.min(lastZoomScale * scale, 1.1)); - - // Only apply scale, no translation - always zoom from center - setZoomScale(newScale); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Center Zoom: ${newScale.toFixed(2)}x`); - } - }; - - const onPinchHandlerStateChange = (event: PinchGestureHandlerGestureEvent) => { - if (event.nativeEvent.state === State.END) { - // Save the current scale as the new baseline, no translation - setLastZoomScale(zoomScale); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Pinch ended - saved scale: ${zoomScale.toFixed(2)}x`); - } - } - }; - - // Reset zoom to appropriate level (1.1x for 16:9, 1x for others) - const resetZoom = () => { - const targetZoom = is16by9Content ? 1.1 : 1; - - setZoomScale(targetZoom); - setLastZoomScale(targetZoom); - // No translation needed for center zoom - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Zoom reset to ${targetZoom}x (16:9: ${is16by9Content})`); - } - }; - - // Recalculate video styles when screen dimensions change - useEffect(() => { - if (videoAspectRatio && screenDimensions.width > 0 && screenDimensions.height > 0) { - const styles = calculateVideoStyles( - videoAspectRatio * 1000, // Reconstruct width from aspect ratio - 1000, // Use 1000 as base height - screenDimensions.width, - screenDimensions.height - ); - setCustomVideoStyles(styles); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Screen dimensions changed, recalculated styles:`, styles); - } - } - }, [screenDimensions, videoAspectRatio]); - - // Lock screen to landscape when component mounts - useEffect(() => { - // Update screen dimensions when they change (orientation changes) - const subscription = Dimensions.addEventListener('change', ({ screen }) => { - setScreenDimensions(screen); - }); - - // Since orientation is now locked before navigation, we can start immediately - const initializePlayer = () => { - // Force StatusBar to be completely hidden - StatusBar.setHidden(true, 'none'); - - // Enable immersive mode with more aggressive settings - enableImmersiveMode(); - - // Start the opening animation immediately - startOpeningAnimation(); - }; - - initializePlayer(); - - // Restore screen orientation and disable immersive mode when component unmounts - return () => { - subscription?.remove(); - const unlockOrientation = async () => { - await ScreenOrientation.unlockAsync(); - }; - unlockOrientation(); - disableImmersiveMode(); - }; - }, []); - - // Opening animation sequence - modified to wait for video load - const startOpeningAnimation = () => { - // Keep everything black until video loads - // Only show loading indicator, no video player fade-in yet - // Note: All animations will be triggered by onLoad when video is ready - }; - - // Complete the opening animation when video loads - const completeOpeningAnimation = () => { - // Start all animations together when video is ready - Animated.parallel([ - // Fade in the video player - Animated.timing(openingFadeAnim, { - toValue: 1, - duration: 600, - useNativeDriver: true, - }), - // Scale up from 80% to 100% and ensure it stays at 100% - Animated.timing(openingScaleAnim, { - toValue: 1, - duration: 700, - useNativeDriver: true, - }), - // Fade out the black background overlay - Animated.timing(backgroundFadeAnim, { - toValue: 0, - duration: 800, - useNativeDriver: true, - }), - ]).start(() => { - // Animation is complete - ensure scale is exactly 1 - openingScaleAnim.setValue(1); - openingFadeAnim.setValue(1); - setIsOpeningAnimationComplete(true); - - // Hide the background overlay completely after animation - setTimeout(() => { - backgroundFadeAnim.setValue(0); - }, 100); - }); - }; - - // Load saved watch progress on mount - useEffect(() => { - const loadWatchProgress = async () => { - if (id && type) { - try { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Checking for saved progress with id=${id}, type=${type}, episodeId=${episodeId || 'none'}`); - } - const savedProgress = await storageService.getWatchProgress(id, type, episodeId); - - if (savedProgress) { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Found saved progress:`, savedProgress); - } - - if (savedProgress.currentTime > 0) { - // Only auto-resume if less than 95% watched (not effectively complete) - const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100; - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Progress percent: ${progressPercent.toFixed(2)}%`); - } - - if (progressPercent < 95) { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Setting initial position to ${savedProgress.currentTime}`); - } - // Set resume position - setResumePosition(savedProgress.currentTime); - - // Check for saved preference - const pref = await AsyncStorage.getItem(RESUME_PREF_KEY); - if (pref === RESUME_PREF.ALWAYS_RESUME) { - setInitialPosition(savedProgress.currentTime); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Auto-resuming based on saved preference`); - } - } else if (pref === RESUME_PREF.ALWAYS_START_OVER) { - setInitialPosition(0); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Auto-starting from beginning based on saved preference`); - } - } else { - // Only show resume overlay if no preference or ALWAYS_ASK - setShowResumeOverlay(true); - } - } else if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Progress >= 95%, starting from beginning`); - } - } - } else if (DEBUG_MODE) { - logger.log(`[VideoPlayer] No saved progress found`); - } - } catch (error) { - logger.error('[VideoPlayer] Error loading watch progress:', error); - } - } else if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Missing id or type, can't load progress. id=${id}, type=${type}`); - } - }; - - loadWatchProgress(); - }, [id, type, episodeId]); - - // Set up interval to save watch progress periodically (every 5 seconds) - useEffect(() => { - if (id && type && !paused && duration > 0) { - // Clear any existing interval - if (progressSaveInterval) { - clearInterval(progressSaveInterval); - } - - // Set up new interval to save progress - const interval = setInterval(() => { - saveWatchProgress(); - }, 5000); - - setProgressSaveInterval(interval); - - // Clean up interval on pause or unmount - return () => { - clearInterval(interval); - setProgressSaveInterval(null); - }; - } - }, [id, type, paused, currentTime, duration]); - - // Save progress one more time when component unmounts - useEffect(() => { - return () => { - if (id && type && duration > 0) { - saveWatchProgress(); - } - }; - }, [id, type, currentTime, duration]); - - // Function to save watch progress - const saveWatchProgress = async () => { - if (id && type && currentTime > 0 && duration > 0) { - const progress = { - currentTime, - duration, - lastUpdated: Date.now() - }; - - try { - await storageService.setWatchProgress(id, type, progress, episodeId); - } catch (error) { - logger.error('[VideoPlayer] Error saving watch progress:', error); - } - } else if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Cannot save progress: id=${id}, type=${type}, currentTime=${currentTime}, duration=${duration}`); - } - }; - - useEffect(() => { - max.value = duration; - }, [duration]); - - const formatTime = (seconds: number) => { - const hours = Math.floor(seconds / 3600); - const mins = Math.floor((seconds % 3600) / 60); - const secs = Math.floor(seconds % 60); - - if (hours > 0) { - return `${hours}:${mins < 10 ? '0' : ''}${mins}:${secs < 10 ? '0' : ''}${secs}`; - } else { - return `${mins}:${secs < 10 ? '0' : ''}${secs}`; - } - }; - - // Simplify the seekToTime function to use VLC's direct methods - const seekToTime = (timeInSeconds: number) => { - if (!isPlayerReady || duration <= 0 || !vlcRef.current) return; - - // Calculate normalized position (0-1) for VLC - const normalizedPosition = Math.max(0, Math.min(timeInSeconds / duration, 1)); - - try { - // Use VLC's direct setPosition method - if (typeof vlcRef.current.setPosition === 'function') { - vlcRef.current.setPosition(normalizedPosition); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Called setPosition with ${normalizedPosition} for time: ${timeInSeconds}s`); - } - } else if (typeof vlcRef.current.seek === 'function') { - // Fallback to seek method if available - vlcRef.current.seek(normalizedPosition); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Called seek with ${normalizedPosition} for time: ${timeInSeconds}s`); - } - } else { - logger.error('[VideoPlayer] No seek method available on VLC player'); - } - } catch (error) { - logger.error('[VideoPlayer] Error during seek operation:', error); - } - }; - - // Enhanced progress bar touch handling with drag support - const handleProgressBarTouch = (event: any) => { - if (!duration || duration <= 0) return; - - const { locationX } = event.nativeEvent; - processProgressTouch(locationX); - }; - - const handleProgressBarDragStart = () => { - setIsDragging(true); - }; - - const handleProgressBarDragMove = (event: any) => { - if (!isDragging || !duration || duration <= 0) return; - - const { locationX } = event.nativeEvent; - processProgressTouch(locationX, true); // Pass true to indicate dragging - }; - - const handleProgressBarDragEnd = () => { - setIsDragging(false); - // Apply the final seek when drag ends - if (pendingSeekValue.current !== null) { - seekToTime(pendingSeekValue.current); - pendingSeekValue.current = null; - } - }; - - // Helper function to process touch position and seek - const processProgressTouch = (locationX: number, isDragging = false) => { - progressBarRef.current?.measure((x, y, width, height, pageX, pageY) => { - // Calculate percentage of touch position relative to progress bar width - const percentage = Math.max(0, Math.min(locationX / width, 1)); - // Calculate time to seek to - const seekTime = percentage * duration; - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Progress touch: ${seekTime.toFixed(1)}s (${(percentage * 100).toFixed(1)}%)`); - } - - // Update the visual progress immediately - progress.value = seekTime; - progressAnim.setValue(percentage); - - // If dragging, update currentTime for visual feedback but don't seek yet - if (isDragging) { - pendingSeekValue.current = seekTime; - setCurrentTime(seekTime); - } else { - // If it's a tap (not dragging), seek immediately - seekToTime(seekTime); - } - }); - }; - - // Update the handleProgress function to not update progress while dragging - const handleProgress = (event: any) => { - if (isDragging) return; // Don't update progress while user is dragging - - const currentTimeInSeconds = event.currentTime / 1000; // VLC gives time in milliseconds - - // Always update state - let VLC manage the timing - if (Math.abs(currentTimeInSeconds - currentTime) > 0.5) { - safeSetState(() => setCurrentTime(currentTimeInSeconds)); - progress.value = currentTimeInSeconds; - - // Animate the progress bar smoothly - const progressPercent = duration > 0 ? currentTimeInSeconds / duration : 0; - Animated.timing(progressAnim, { - toValue: progressPercent, - duration: 250, - useNativeDriver: false, - }).start(); - - // Update buffered position - const bufferedTime = event.bufferTime / 1000 || currentTimeInSeconds; - safeSetState(() => setBuffered(bufferedTime)); - } - }; - - // Enhanced onLoad handler to detect aspect ratio and mark player as ready - const onLoad = (data: any) => { - setDuration(data.duration / 1000); // VLC returns duration in milliseconds - max.value = data.duration / 1000; - - // Calculate and detect aspect ratio with custom styling - if (data.videoSize && data.videoSize.width && data.videoSize.height) { - const aspectRatio = data.videoSize.width / data.videoSize.height; - setVideoAspectRatio(aspectRatio); - - // Check if it's 16:9 content (1.777... ≈ 16/9) - const is16x9 = Math.abs(aspectRatio - (16/9)) < 0.1; - setIs16by9Content(is16x9); - - // Auto-zoom 16:9 content to 1.1x to fill more screen - if (is16x9) { - setZoomScale(1.1); - setLastZoomScale(1.1); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Auto-zoomed 16:9 content to 1.1x`); - } - } else { - // Reset zoom for non-16:9 content - setZoomScale(1); - setLastZoomScale(1); - } - - // Calculate custom video styles for precise control - const styles = calculateVideoStyles( - data.videoSize.width, - data.videoSize.height, - screenDimensions.width, - screenDimensions.height - ); - setCustomVideoStyles(styles); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Video aspect ratio: ${aspectRatio.toFixed(3)} (16:9: ${is16x9})`); - logger.log(`[VideoPlayer] Applied custom styles:`, styles); - } - } else { - // Fallback: assume 16:9 and apply default styles with auto-zoom - setIs16by9Content(true); - setZoomScale(1.1); - setLastZoomScale(1.1); - const defaultStyles = { - position: 'absolute', - top: 0, - left: 0, - width: screenDimensions.width, - height: screenDimensions.height, - }; - setCustomVideoStyles(defaultStyles); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Could not detect video size, using default 16:9 styles with 1.1x zoom`); - } - } - - // Mark player as ready for seeking - setIsPlayerReady(true); - - // Get audio and subtitle tracks from onLoad data - const audioTracksFromLoad = data.audioTracks || []; - const textTracksFromLoad = data.textTracks || []; - setVlcAudioTracks(audioTracksFromLoad); - setVlcTextTracks(textTracksFromLoad); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Video loaded with duration: ${data.duration / 1000}`); - logger.log(`[VideoPlayer] Screen dimensions: ${screenDimensions.width}x${screenDimensions.height}`); - logger.log(`[VideoPlayer] VLC Player custom styles applied`); - const methods = Object.keys(vlcRef.current || {}).filter( - key => typeof vlcRef.current[key] === 'function' - ); - logger.log('[VideoPlayer] Available VLC methods:', methods); - - // Log track-related methods specifically - const trackMethods = methods.filter(method => - method.toLowerCase().includes('track') || - method.toLowerCase().includes('audio') || - method.toLowerCase().includes('subtitle') || - method.toLowerCase().includes('text') - ); - logger.log('[VideoPlayer] Track-related VLC methods:', trackMethods); - - logger.log('[VideoPlayer] Available audio tracks:', audioTracksFromLoad); - logger.log('[VideoPlayer] Available subtitle tracks:', textTracksFromLoad); - } - - // Set default selected tracks - if (audioTracksFromLoad.length > 1) { // More than just "Disable" - const firstEnabledAudio = audioTracksFromLoad.find((t: any) => t.id !== -1); - if(firstEnabledAudio) { - setSelectedAudioTrack(firstEnabledAudio.id); - } - } else if (audioTracksFromLoad.length > 0) { - setSelectedAudioTrack(audioTracksFromLoad[0].id); - } - // Subtitles default to disabled (-1) - - // Auto-search for English subtitles if IMDb ID is available - if (imdbId && !customSubtitles.length) { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Auto-searching for English subtitles with IMDb ID: ${imdbId}`); - } - setTimeout(() => { - fetchAvailableSubtitles(imdbId, true); // true for autoSelectEnglish - }, 2000); // Delay to let video start playing first - } - - // If we have an initial position to seek to, do it now - if (initialPosition !== null && !isInitialSeekComplete) { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Will seek to saved position: ${initialPosition}`); - } - - // Seek with a short delay to ensure video is ready - setTimeout(() => { - if (vlcRef.current && duration > 0 && isMounted.current) { - seekToTime(initialPosition); - setIsInitialSeekComplete(true); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Initial seek completed to position: ${initialPosition}s`); - } - } - }, 1000); - } - - // Mark video as loaded and complete opening animation - setIsVideoLoaded(true); - completeOpeningAnimation(); - }; - - const skip = (seconds: number) => { - if (vlcRef.current) { - const newTime = Math.max(0, Math.min(currentTime + seconds, duration)); - seekToTime(newTime); - } - }; - - const onAudioTracks = (data: { audioTracks: AudioTrack[] }) => { - const tracks = data.audioTracks || []; - setAudioTracks(tracks); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Available audio tracks:`, tracks); - } - }; - - const onTextTracks = (e: Readonly<{ textTracks: TextTrack[] }>) => { - const tracks = e.textTracks || []; - setTextTracks(tracks); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Available subtitle tracks:`, tracks); - } - }; - - // Custom aspect ratio control - now toggles between 1x and 1.1x zoom - const cycleAspectRatio = () => { - const newZoom = zoomScale === 1.1 ? 1 : 1.1; - - setZoomScale(newZoom); - setZoomTranslateX(0); - setZoomTranslateY(0); - setLastZoomScale(newZoom); - setLastTranslateX(0); - setLastTranslateY(0); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Toggled zoom to ${newZoom}x`); - } - }; - - // Enhanced immersive mode function - const enableImmersiveMode = () => { - // Force hide status bar immediately without animation - StatusBar.setHidden(true, 'none'); - - if (Platform.OS === 'android') { - // Use multiple methods to ensure complete immersion - try { - // Method 1: RNImmersiveMode - RNImmersiveMode.setBarMode('FullSticky'); - RNImmersiveMode.fullLayout(true); - - // Method 2: Additional native module call if available - if (NativeModules.StatusBarManager) { - NativeModules.StatusBarManager.setHidden(true); - } - } catch (error) { - console.log('Immersive mode error:', error); - } - } - - // For iOS, ensure status bar is hidden - if (Platform.OS === 'ios') { - StatusBar.setHidden(true, 'none'); - } - }; - - // Function to disable immersive mode - const disableImmersiveMode = () => { - StatusBar.setHidden(false); - - if (Platform.OS === 'android') { - // Restore normal mode using setBarMode - RNImmersiveMode.setBarMode('Normal'); - - // Alternative: disable fullLayout - RNImmersiveMode.fullLayout(false); - } - }; - - // Function to handle closing the video player - const handleClose = () => { - // First unlock the screen orientation - const unlockOrientation = async () => { - await ScreenOrientation.unlockAsync(); - }; - unlockOrientation(); - - // Disable immersive mode - disableImmersiveMode(); - - // Navigate back - navigation.goBack(); - }; - - // Add debug logs for modal visibility - useEffect(() => { - if (showAudioModal && DEBUG_MODE) { - logger.log("[VideoPlayer] Audio modal should be visible now"); - logger.log("[VideoPlayer] Available audio tracks:", audioTracks); - } - }, [showAudioModal, audioTracks]); - - useEffect(() => { - if (showSubtitleModal && DEBUG_MODE) { - logger.log("[VideoPlayer] Subtitle modal should be visible now"); - logger.log("[VideoPlayer] Available text tracks:", textTracks); - } - }, [showSubtitleModal, textTracks]); - - // Load resume preference on mount - useEffect(() => { - const loadResumePreference = async () => { - try { - const pref = await AsyncStorage.getItem(RESUME_PREF_KEY); - if (pref) { - setResumePreference(pref); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Loaded resume preference: ${pref}`); - } - - // If user has a preference, apply it automatically - if (pref === RESUME_PREF.ALWAYS_RESUME && resumePosition !== null) { - setShowResumeOverlay(false); - setInitialPosition(resumePosition); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Auto-resuming based on saved preference`); - } - } else if (pref === RESUME_PREF.ALWAYS_START_OVER) { - setShowResumeOverlay(false); - setInitialPosition(0); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Auto-starting from beginning based on saved preference`); - } - } - } - } catch (error) { - logger.error('[VideoPlayer] Error loading resume preference:', error); - } - }; - - loadResumePreference(); - }, [resumePosition]); - - // Reset resume preference - const resetResumePreference = async () => { - try { - await AsyncStorage.removeItem(RESUME_PREF_KEY); - setResumePreference(null); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Reset resume preference`); - } - } catch (error) { - logger.error('[VideoPlayer] Error resetting resume preference:', error); - } - }; - - // Handle resume from overlay - modified for VLC - const handleResume = async () => { - if (resumePosition !== null && vlcRef.current) { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Resuming from ${resumePosition}`); - } - - // Save preference if remember choice is checked - if (rememberChoice) { - try { - await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_RESUME); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Saved resume preference: ${RESUME_PREF.ALWAYS_RESUME}`); - } - } catch (error) { - logger.error('[VideoPlayer] Error saving resume preference:', error); - } - } - - // Set initial position to trigger seek - setInitialPosition(resumePosition); - // Hide overlay - setShowResumeOverlay(false); - - // Seek to position with VLC - setTimeout(() => { - if (vlcRef.current) { - seekToTime(resumePosition); - } - }, 500); - } - }; - - // Handle start from beginning - modified for VLC - const handleStartFromBeginning = async () => { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Starting from beginning`); - } - - // Save preference if remember choice is checked - if (rememberChoice) { - try { - await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_START_OVER); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Saved resume preference: ${RESUME_PREF.ALWAYS_START_OVER}`); - } - } catch (error) { - logger.error('[VideoPlayer] Error saving resume preference:', error); - } - } - - // Hide overlay - setShowResumeOverlay(false); - // Set initial position to 0 - setInitialPosition(0); - // Make sure we seek to beginning - if (vlcRef.current) { - seekToTime(0); - setCurrentTime(0); - progress.value = 0; - } - }; - - // Update the showControls logic to include animation - const toggleControls = () => { - // Start fade animation - Animated.timing(fadeAnim, { - toValue: showControls ? 0 : 1, - duration: 300, - useNativeDriver: true, - }).start(); - - // Update state - setShowControls(!showControls); - }; - - // Handle VLC errors - const handleError = (error: any) => { - logger.error('[VideoPlayer] Playback Error:', error); - // Optionally, you could show an error message to the user here - }; - - // Handle VLC buffering - const onBuffering = (event: any) => { - setIsBuffering(event.isBuffering); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Buffering: ${event.isBuffering}`); - } - }; - - // Handle VLC playback ended - const onEnd = () => { - // Your existing playback ended logic here - }; - - // Function to select audio track in VLC - const selectAudioTrack = (trackId: number) => { - setSelectedAudioTrack(trackId); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Selected audio track ID: ${trackId}`); - } - }; - - // Function to select subtitle track in VLC - const selectTextTrack = (trackId: number) => { - if (trackId === -999) { // Special ID for custom subtitles - setUseCustomSubtitles(true); - setSelectedTextTrack(-1); // Disable VLC subtitles - } else { - setUseCustomSubtitles(false); - setSelectedTextTrack(trackId); - } - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Selected subtitle track ID: ${trackId}, custom: ${trackId === -999}`); - } - }; - - // Update subtitle modal to use VLC subtitle tracks - const renderSubtitleModal = () => { - if (!showSubtitleModal) return null; - - return ( - - - - Subtitle Settings - setShowSubtitleModal(false)} - > - - - - - - - - {/* External Subtitles Section - Priority */} - - External Subtitles - High quality subtitles with size control - - {/* Custom subtitles option - show if loaded */} - {customSubtitles.length > 0 ? ( - { - selectTextTrack(-999); - setShowSubtitleModal(false); - }} - > - - - - - Custom Subtitles - - {customSubtitles.length} cues • Size adjustable - - - {useCustomSubtitles && ( - - - - )} - - ) : null} - - {/* Search for external subtitles */} - { - setShowSubtitleModal(false); - fetchAvailableSubtitles(); - }} - disabled={isLoadingSubtitleList} - > - - {isLoadingSubtitleList ? ( - - ) : ( - - )} - - {isLoadingSubtitleList ? 'Searching...' : 'Search Online Subtitles'} - - - - - - {/* Subtitle Size Controls - Only for custom subtitles */} - {useCustomSubtitles && ( - - Size Control - - - - - - {subtitleSize}px - Font Size - - - - - - - )} - - {/* Built-in Subtitles Section */} - - Built-in Subtitles - System default sizing • No customization - - {/* Off option */} - { - selectTextTrack(-1); - setShowSubtitleModal(false); - }} - > - - - - - Disabled - No subtitles - - {(selectedTextTrack === -1 && !useCustomSubtitles) && ( - - - - )} - - - {/* Available built-in subtitle tracks */} - {vlcTextTracks.length > 0 ? vlcTextTracks.map(track => ( - { - selectTextTrack(track.id); - setShowSubtitleModal(false); - }} - > - - - - - - {getTrackDisplayName(track)} - - - Built-in track • System font size - - - {(selectedTextTrack === track.id && !useCustomSubtitles) && ( - - - - )} - - )) : ( - - - No built-in subtitles available - - )} - - - - - - ); - }; - - // Render subtitle language selection modal - const renderSubtitleLanguageModal = () => { - if (!showSubtitleLanguageModal) return null; - - return ( - - - - Select Language - setShowSubtitleLanguageModal(false)} - > - - - - - - - {availableSubtitles.length > 0 ? availableSubtitles.map(subtitle => ( - loadWyzieSubtitle(subtitle)} - disabled={isLoadingSubtitles} - > - - - - - {formatLanguage(subtitle.language)} - - - {subtitle.display} - - - - {isLoadingSubtitles && ( - - )} - - )) : ( - - - - No subtitles found for this content - - - )} - - - - - ); - }; - - // Update the getInfo method for VLC - const getInfo = async () => { - if (vlcRef.current) { - try { - const position = await vlcRef.current.getPosition(); - const lengthResult = await vlcRef.current.getLength(); - return { - currentTime: position, - duration: lengthResult / 1000 // Convert to seconds - }; - } catch (e) { - logger.error('[VideoPlayer] Error getting playback info:', e); - return { - currentTime: currentTime, - duration: duration - }; - } - } - return { - currentTime: 0, - duration: 0 - }; - }; - - // VLC specific method to set playback speed - const changePlaybackSpeed = (speed: number) => { - if (vlcRef.current) { - if (typeof vlcRef.current.setRate === 'function') { - vlcRef.current.setRate(speed); - } else if (typeof vlcRef.current.setPlaybackRate === 'function') { - vlcRef.current.setPlaybackRate(speed); - } - setPlaybackSpeed(speed); - } - }; - - // VLC specific method for volume control - const setVolume = (volumeLevel: number) => { - if (vlcRef.current) { - // VLC volume is typically between 0-200 - if (typeof vlcRef.current.setVolume === 'function') { - vlcRef.current.setVolume(volumeLevel * 200); - } - } - }; - - // Added back the togglePlayback function - const togglePlayback = () => { - if (vlcRef.current) { - if (paused) { - // Check if resume function exists - if (typeof vlcRef.current.resume === 'function') { - vlcRef.current.resume(); - } else if (typeof vlcRef.current.play === 'function') { - vlcRef.current.play(); - } else { - // Fallback - use setPaused method or property if available - vlcRef.current.setPaused && vlcRef.current.setPaused(false); - } - } else { - // Check if pause function exists - if (typeof vlcRef.current.pause === 'function') { - vlcRef.current.pause(); - } else { - // Fallback - use setPaused method or property if available - vlcRef.current.setPaused && vlcRef.current.setPaused(true); - } - } - setPaused(!paused); - } - }; - - // Re-add the renderAudioModal function - const renderAudioModal = () => { - if (!showAudioModal) return null; - - return ( - - - - Audio - setShowAudioModal(false)} - > - - - - - - - {vlcAudioTracks.length > 0 ? vlcAudioTracks.map(track => ( - { - selectAudioTrack(track.id); - setShowAudioModal(false); - }} - > - - - {getTrackDisplayName(track)} - - {(track.name && track.language) && ( - {track.name} - )} - - {selectedAudioTrack === track.id && ( - - - - )} - - )) : ( - - - No audio tracks available - - )} - - - - - ); - }; - - // Use a ref to track if we're mounted to prevent state updates after unmount - // This helps prevent potential memory leaks and strange behaviors with navigation - const isMounted = useRef(true); - - // Clean up when component unmounts - useEffect(() => { - return () => { - isMounted.current = false; - if (seekDebounceTimer.current) { - clearTimeout(seekDebounceTimer.current); - } - }; - }, []); - - // Wrap all setState calls with this check - const safeSetState = (setter: any) => { - if (isMounted.current) { - setter(); - } - }; - - // Add subtitle size management functions - const loadSubtitleSize = async () => { - try { - const savedSize = await AsyncStorage.getItem(SUBTITLE_SIZE_KEY); - if (savedSize) { - setSubtitleSize(parseInt(savedSize, 10)); - } - } catch (error) { - logger.error('[VideoPlayer] Error loading subtitle size:', error); - } - }; - - const saveSubtitleSize = async (size: number) => { - try { - await AsyncStorage.setItem(SUBTITLE_SIZE_KEY, size.toString()); - setSubtitleSize(size); - } catch (error) { - logger.error('[VideoPlayer] Error saving subtitle size:', error); - } - }; - - // Enhanced SRT parser function - more robust - const parseSRT = (srtContent: string): SubtitleCue[] => { - const cues: SubtitleCue[] = []; - - if (!srtContent || srtContent.trim().length === 0) { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] SRT Parser: Empty content provided`); - } - return cues; - } - - // Normalize line endings and clean up the content - const normalizedContent = srtContent - .replace(/\r\n/g, '\n') // Convert Windows line endings - .replace(/\r/g, '\n') // Convert Mac line endings - .trim(); - - // Split by double newlines, but also handle cases with multiple empty lines - const blocks = normalizedContent.split(/\n\s*\n/).filter(block => block.trim().length > 0); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] SRT Parser: Found ${blocks.length} blocks after normalization`); - logger.log(`[VideoPlayer] SRT Parser: First few characters: "${normalizedContent.substring(0, 300)}"`); - } - - for (let i = 0; i < blocks.length; i++) { - const block = blocks[i].trim(); - const lines = block.split('\n').map(line => line.trim()).filter(line => line.length > 0); - - if (lines.length >= 3) { - // Find the timestamp line (could be line 1 or 2, depending on numbering) - let timeLineIndex = -1; - let timeMatch = null; - - for (let j = 0; j < Math.min(3, lines.length); j++) { - // More flexible time pattern matching - timeMatch = lines[j].match(/(\d{1,2}):(\d{2}):(\d{2})[,.](\d{3})\s*-->\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{3})/); - if (timeMatch) { - timeLineIndex = j; - break; - } - } - - if (timeMatch && timeLineIndex !== -1) { - try { - const startTime = - parseInt(timeMatch[1]) * 3600 + - parseInt(timeMatch[2]) * 60 + - parseInt(timeMatch[3]) + - parseInt(timeMatch[4]) / 1000; - - const endTime = - parseInt(timeMatch[5]) * 3600 + - parseInt(timeMatch[6]) * 60 + - parseInt(timeMatch[7]) + - parseInt(timeMatch[8]) / 1000; - - // Get text lines (everything after the timestamp line) - const textLines = lines.slice(timeLineIndex + 1); - if (textLines.length > 0) { - const text = textLines - .join('\n') - .replace(/<[^>]*>/g, '') // Remove HTML tags - .replace(/\{[^}]*\}/g, '') // Remove subtitle formatting tags like {italic} - .replace(/\\N/g, '\n') // Handle \N newlines - .trim(); - - if (text.length > 0) { - cues.push({ - start: startTime, - end: endTime, - text: text - }); - - if (DEBUG_MODE && (i < 5 || cues.length <= 10)) { - logger.log(`[VideoPlayer] SRT Parser: Cue ${cues.length}: ${startTime.toFixed(3)}s-${endTime.toFixed(3)}s: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`); - } - } - } - } catch (error) { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] SRT Parser: Error parsing times for block ${i + 1}: ${error}`); - } - } - } else if (DEBUG_MODE) { - logger.log(`[VideoPlayer] SRT Parser: No valid timestamp found in block ${i + 1}. Lines: ${JSON.stringify(lines.slice(0, 3))}`); - } - } else if (DEBUG_MODE && block.length > 0) { - logger.log(`[VideoPlayer] SRT Parser: Block ${i + 1} has insufficient lines (${lines.length}): "${block.substring(0, 100)}"`); - } - } - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] SRT Parser: Successfully parsed ${cues.length} subtitle cues`); - if (cues.length > 0) { - logger.log(`[VideoPlayer] SRT Parser: Time range: ${cues[0].start.toFixed(1)}s to ${cues[cues.length-1].end.toFixed(1)}s`); - } - } - - return cues; - }; - - // Fetch available subtitles from Wyzie API - const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = false) => { - const targetImdbId = imdbIdParam || imdbId; - if (!targetImdbId) { - logger.error('[VideoPlayer] No IMDb ID available for subtitle search'); - return; - } - - setIsLoadingSubtitleList(true); - try { - // Build search URL with season and episode parameters for TV shows - let searchUrl = `https://sub.wyzie.ru/search?id=${targetImdbId}&encoding=utf-8&source=all`; - - // Add season and episode parameters if available (for TV shows) - if (season && episode) { - searchUrl += `&season=${season}&episode=${episode}`; - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Searching for subtitles with IMDb ID: ${targetImdbId}, Season: ${season}, Episode: ${episode}`); - } - } else { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Searching for subtitles with IMDb ID: ${targetImdbId} (movie or no season/episode info)`); - } - } - - const response = await fetch(searchUrl); - const subtitles: WyzieSubtitle[] = await response.json(); - - // Filter out duplicates and sort by language - const uniqueSubtitles = subtitles.reduce((acc, current) => { - const exists = acc.find(item => item.language === current.language); - if (!exists) { - acc.push(current); - } - return acc; - }, [] as WyzieSubtitle[]); - - // Sort alphabetically by display name - uniqueSubtitles.sort((a, b) => a.display.localeCompare(b.display)); - - setAvailableSubtitles(uniqueSubtitles); - - if (autoSelectEnglish) { - // Try to find English subtitles - const englishSubtitle = uniqueSubtitles.find(sub => - sub.language.toLowerCase() === 'eng' || - sub.language.toLowerCase() === 'en' || - sub.display.toLowerCase().includes('english') - ); - - if (englishSubtitle) { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Auto-selecting English subtitle: ${englishSubtitle.display}`); - } - loadWyzieSubtitle(englishSubtitle); - return; - } else if (DEBUG_MODE) { - logger.log(`[VideoPlayer] No English subtitles found for auto-selection`); - } - } - - // Only show the modal if not auto-selecting or if no English subtitles found - if (!autoSelectEnglish) { - setShowSubtitleLanguageModal(true); - } - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Found ${uniqueSubtitles.length} unique subtitle languages`); - } - } catch (error) { - logger.error('[VideoPlayer] Error fetching subtitles from Wyzie API:', error); - } finally { - setIsLoadingSubtitleList(false); - } - }; - - // Load subtitle from selected Wyzie entry - const loadWyzieSubtitle = async (subtitle: WyzieSubtitle) => { - setShowSubtitleLanguageModal(false); - setIsLoadingSubtitles(true); - - try { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Loading subtitle: ${subtitle.display} from ${subtitle.url}`); - } - - const response = await fetch(subtitle.url); - const srtContent = await response.text(); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Downloaded subtitle content length: ${srtContent.length} characters`); - logger.log(`[VideoPlayer] First 200 characters of subtitle: ${srtContent.substring(0, 200)}`); - } - - const parsedCues = parseSRT(srtContent); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Parsed ${parsedCues.length} subtitle cues`); - if (parsedCues.length > 0) { - logger.log(`[VideoPlayer] First cue: ${parsedCues[0].start}s-${parsedCues[0].end}s: "${parsedCues[0].text}"`); - logger.log(`[VideoPlayer] Last cue: ${parsedCues[parsedCues.length-1].start}s-${parsedCues[parsedCues.length-1].end}s: "${parsedCues[parsedCues.length-1].text}"`); - } - } - - setCustomSubtitles(parsedCues); - setUseCustomSubtitles(true); - - // Disable VLC's built-in subtitles when using custom ones - setSelectedTextTrack(-1); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Successfully loaded subtitle: useCustomSubtitles=true, customSubtitles.length=${parsedCues.length}`); - } - } catch (error) { - logger.error('[VideoPlayer] Error loading Wyzie subtitle:', error); - } finally { - setIsLoadingSubtitles(false); - } - }; - - // Load external subtitle file (keep for backwards compatibility) - const loadExternalSubtitles = async (subtitleUrl: string) => { - if (!subtitleUrl) return; - - setIsLoadingSubtitles(true); - try { - const response = await fetch(subtitleUrl); - const srtContent = await response.text(); - const parsedCues = parseSRT(srtContent); - setCustomSubtitles(parsedCues); - setUseCustomSubtitles(true); - - // Disable VLC's built-in subtitles when using custom ones - setSelectedTextTrack(-1); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Loaded ${parsedCues.length} subtitle cues from external file`); - } - } catch (error) { - logger.error('[VideoPlayer] Error loading external subtitles:', error); - } finally { - setIsLoadingSubtitles(false); - } - }; - - // Update current subtitle based on playback time - useEffect(() => { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Subtitle useEffect - useCustomSubtitles: ${useCustomSubtitles}, customSubtitles.length: ${customSubtitles.length}, currentTime: ${currentTime.toFixed(3)}`); - - // Show detailed info about subtitle cues for debugging - if (useCustomSubtitles && customSubtitles.length > 0 && customSubtitles.length <= 5) { - logger.log(`[VideoPlayer] All ${customSubtitles.length} subtitle cues:`); - customSubtitles.forEach((cue, index) => { - const isActive = currentTime >= cue.start && currentTime <= cue.end; - logger.log(`[VideoPlayer] Cue ${index + 1}: ${cue.start.toFixed(3)}s-${cue.end.toFixed(3)}s ${isActive ? '(ACTIVE)' : ''}: "${cue.text.substring(0, 50)}${cue.text.length > 50 ? '...' : ''}"`); - }); - } else if (useCustomSubtitles && customSubtitles.length > 5) { - // For larger subtitle files, just show nearby cues - const nearbyCues = customSubtitles.filter(cue => - Math.abs(cue.start - currentTime) <= 10 || Math.abs(cue.end - currentTime) <= 10 - ); - if (nearbyCues.length > 0) { - logger.log(`[VideoPlayer] Nearby subtitle cues (within 10s):`); - nearbyCues.slice(0, 3).forEach((cue, index) => { - const isActive = currentTime >= cue.start && currentTime <= cue.end; - logger.log(`[VideoPlayer] Nearby cue: ${cue.start.toFixed(3)}s-${cue.end.toFixed(3)}s ${isActive ? '(ACTIVE)' : ''}: "${cue.text.substring(0, 50)}${cue.text.length > 50 ? '...' : ''}"`); - }); - } - } - } - - if (!useCustomSubtitles || customSubtitles.length === 0) { - if (currentSubtitle !== '') { - setCurrentSubtitle(''); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Cleared subtitle - useCustomSubtitles: ${useCustomSubtitles}, customSubtitles.length: ${customSubtitles.length}`); - } - } - return; - } - - const currentCue = customSubtitles.find(cue => - currentTime >= cue.start && currentTime <= cue.end - ); - - const newSubtitle = currentCue ? currentCue.text : ''; - - if (DEBUG_MODE && newSubtitle !== currentSubtitle) { - logger.log(`[VideoPlayer] Subtitle changed from "${currentSubtitle}" to "${newSubtitle}" at time ${currentTime.toFixed(3)}`); - if (currentCue) { - logger.log(`[VideoPlayer] Current cue: ${currentCue.start.toFixed(3)}s - ${currentCue.end.toFixed(3)}s: "${currentCue.text}"`); - } - } - - setCurrentSubtitle(newSubtitle); - }, [currentTime, customSubtitles, useCustomSubtitles]); - - // Load subtitle size preference on mount - useEffect(() => { - loadSubtitleSize(); - }, []); - - // Add subtitle size adjustment functions - const increaseSubtitleSize = () => { - const newSize = Math.min(subtitleSize + 2, 32); - saveSubtitleSize(newSize); - }; - - const decreaseSubtitleSize = () => { - const newSize = Math.max(subtitleSize - 2, 8); - saveSubtitleSize(newSize); - }; - - - - return ( - - {/* Opening Animation Overlay - covers the entire screen during transition */} - - - - Loading video... - - - - {/* Animated Video Player Container - ensure no transform issues */} - - - - - - - - - - - {/* Progress bar with enhanced touch handling */} - - - - - {/* Buffered Progress */} - - {/* Animated Progress */} - - - - - - {formatTime(currentTime)} - {formatTime(duration)} - - - - {/* Controls Overlay - Using Animated.View */} - - {/* Top Gradient & Header */} - - - {/* Title Section - Enhanced with metadata */} - - {title} - {/* Show season and episode for series */} - {season && episode && ( - - S{season}E{episode} {episodeTitle && `• ${episodeTitle}`} - - )} - {/* Show year, quality, and provider */} - - {year && {year}} - {quality && {quality}} - {streamProvider && via {streamProvider}} - - - - - - - - - {/* Center Controls (Play/Pause, Skip) */} - - skip(-10)} style={styles.skipButton}> - - 10 - - - - - skip(10)} style={styles.skipButton}> - - 10 - - - - {/* Bottom Gradient */} - - - {/* Bottom Buttons Row */} - - {/* Speed Button */} - - - Speed ({playbackSpeed}x) - - - {/* Fill/Cover Button - Updated to show fill/cover modes */} - - - - {zoomScale === 1.1 ? 'Fill' : 'Cover'} - - - - {/* Audio Button - Updated to use vlcAudioTracks */} - setShowAudioModal(true)} - disabled={vlcAudioTracks.length <= 1} - > - - - {`Audio: ${getTrackDisplayName(vlcAudioTracks.find(t => t.id === selectedAudioTrack) || {id: -1, name: 'Default'})}`} - - - - {/* Subtitle Button - Always available for external subtitle search */} - setShowSubtitleModal(true)} - > - - - {useCustomSubtitles - ? 'Subtitles: Custom' - : (selectedTextTrack === -1) - ? 'Subtitles' - : `Subtitles: ${getTrackDisplayName(vlcTextTracks.find(t => t.id === selectedTextTrack) || {id: -1, name: 'On'})}`} - - - - - - - - {/* Custom Subtitle Overlay - Enhanced visibility and debugging */} - {(useCustomSubtitles && currentSubtitle) && ( - - - - {currentSubtitle} - - - - )} - - {/* Resume Overlay */} - {showResumeOverlay && resumePosition !== null && ( - - - - - - - - Continue Watching - - {title} - {season && episode && ` • S${season}E${episode}`} - - - - 0 ? (resumePosition / duration) * 100 : 0}%` } - ]} - /> - - - {formatTime(resumePosition)} {duration > 0 ? `/ ${formatTime(duration)}` : ''} - - - - - - {/* Remember choice checkbox */} - setRememberChoice(!rememberChoice)} - activeOpacity={0.7} - > - - - {rememberChoice && } - - Remember my choice - - - {resumePreference && ( - - Reset - - )} - - - - - - Start Over - - - - Resume - - - - - )} - - - - {/* Use the new modal rendering functions */} - {renderAudioModal()} - {renderSubtitleModal()} - {renderSubtitleLanguageModal()} - - ); -}; - -const styles = StyleSheet.create({ - container: { - backgroundColor: '#000', - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - margin: 0, - padding: 0, - }, - videoContainer: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - margin: 0, - padding: 0, - }, - video: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - margin: 0, - padding: 0, - }, - controlsContainer: { - ...StyleSheet.absoluteFillObject, - justifyContent: 'space-between', - margin: 0, - padding: 0, - }, - topGradient: { - paddingTop: 20, - paddingHorizontal: 20, - paddingBottom: 10, - }, - bottomGradient: { - paddingBottom: 20, - paddingHorizontal: 20, - paddingTop: 20, - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-start', - }, - titleSection: { - flex: 1, - marginRight: 10, - }, - title: { - color: 'white', - fontSize: 18, - fontWeight: 'bold', - }, - episodeInfo: { - color: 'rgba(255, 255, 255, 0.9)', - fontSize: 14, - marginTop: 3, - }, - metadataRow: { - flexDirection: 'row', - alignItems: 'center', - marginTop: 5, - flexWrap: 'wrap', - }, - metadataText: { - color: 'rgba(255, 255, 255, 0.7)', - fontSize: 12, - marginRight: 8, - }, - qualityBadge: { - backgroundColor: '#E50914', - paddingHorizontal: 6, - paddingVertical: 2, - borderRadius: 4, - marginRight: 8, - }, - qualityText: { - color: 'white', - fontSize: 10, - fontWeight: 'bold', - }, - providerText: { - color: 'rgba(255, 255, 255, 0.7)', - fontSize: 12, - fontStyle: 'italic', - }, - closeButton: { - padding: 8, - }, - controls: { - position: 'absolute', - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - gap: 40, - left: 0, - right: 0, - top: '50%', - transform: [{ translateY: -30 }], - zIndex: 1000, - }, - playButton: { - justifyContent: 'center', - alignItems: 'center', - padding: 10, - }, - skipButton: { - alignItems: 'center', - justifyContent: 'center', - }, - skipText: { - color: 'white', - fontSize: 12, - marginTop: 2, - }, - bottomControls: { - gap: 12, - }, - sliderContainer: { - position: 'absolute', - bottom: 55, - left: 0, - right: 0, - paddingHorizontal: 20, - zIndex: 1000, - }, - progressTouchArea: { - height: 30, - justifyContent: 'center', - width: '100%', - }, - progressBarContainer: { - height: 4, - backgroundColor: 'rgba(255, 255, 255, 0.2)', - borderRadius: 2, - overflow: 'hidden', - marginHorizontal: 4, - position: 'relative', - }, - bufferProgress: { - position: 'absolute', - left: 0, - top: 0, - bottom: 0, - backgroundColor: 'rgba(255, 255, 255, 0.4)', - }, - progressBarFill: { - position: 'absolute', - left: 0, - top: 0, - bottom: 0, - backgroundColor: '#E50914', - height: '100%', - }, - timeDisplay: { - flexDirection: 'row', - justifyContent: 'space-between', - width: '100%', - paddingHorizontal: 4, - marginTop: 4, - marginBottom: 8, - }, - duration: { - color: 'white', - fontSize: 12, - fontWeight: '500', - }, - bottomButtons: { - flexDirection: 'row', - justifyContent: 'space-around', - alignItems: 'center', - }, - bottomButton: { - flexDirection: 'row', - alignItems: 'center', - gap: 5, - }, - bottomButtonText: { - color: 'white', - fontSize: 12, - }, - modalOverlay: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.7)', - }, - modalContent: { - width: '80%', - maxHeight: '70%', - backgroundColor: '#222', - borderRadius: 10, - overflow: 'hidden', - zIndex: 1000, - elevation: 5, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.8, - shadowRadius: 5, - }, - modalHeader: { - padding: 16, - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - borderBottomWidth: 1, - borderBottomColor: '#333', - }, - modalTitle: { - color: 'white', - fontSize: 18, - fontWeight: 'bold', - }, - trackList: { - padding: 10, - }, - trackItem: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: 15, - borderRadius: 5, - marginVertical: 5, - }, - selectedTrackItem: { - backgroundColor: 'rgba(229, 9, 20, 0.2)', - }, - trackLabel: { - color: 'white', - fontSize: 16, - }, - noTracksText: { - color: 'white', - fontSize: 16, - textAlign: 'center', - padding: 20, - }, - fullscreenOverlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(0,0,0,0.85)', - justifyContent: 'center', - alignItems: 'center', - zIndex: 2000, - }, - enhancedModalContainer: { - width: 300, - maxHeight: '70%', - backgroundColor: '#181818', - borderRadius: 8, - overflow: 'hidden', - shadowColor: '#000', - shadowOffset: { width: 0, height: 6 }, - shadowOpacity: 0.4, - shadowRadius: 10, - elevation: 8, - }, - enhancedModalHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 12, - borderBottomWidth: 1, - borderBottomColor: '#333', - }, - enhancedModalTitle: { - color: 'white', - fontSize: 18, - fontWeight: 'bold', - }, - enhancedCloseButton: { - padding: 4, - }, - trackListScrollContainer: { - maxHeight: 350, - }, - trackListContainer: { - padding: 6, - }, - enhancedTrackItem: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: 10, - marginVertical: 2, - borderRadius: 6, - backgroundColor: '#222', - }, - trackInfoContainer: { - flex: 1, - marginRight: 8, - }, - trackPrimaryText: { - color: 'white', - fontSize: 14, - fontWeight: '500', - }, - trackSecondaryText: { - color: '#aaa', - fontSize: 11, - marginTop: 2, - }, - selectedIndicatorContainer: { - width: 24, - height: 24, - borderRadius: 12, - backgroundColor: 'rgba(229, 9, 20, 0.15)', - justifyContent: 'center', - alignItems: 'center', - }, - emptyStateContainer: { - alignItems: 'center', - justifyContent: 'center', - padding: 20, - }, - emptyStateText: { - color: '#888', - fontSize: 14, - marginTop: 8, - textAlign: 'center', - }, - resumeOverlay: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0, 0, 0, 0.7)', - justifyContent: 'center', - alignItems: 'center', - zIndex: 1000, - }, - resumeContainer: { - width: '80%', - maxWidth: 500, - borderRadius: 12, - padding: 20, - shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 6, - elevation: 8, - }, - resumeContent: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 20, - }, - resumeIconContainer: { - marginRight: 16, - width: 50, - height: 50, - borderRadius: 25, - backgroundColor: 'rgba(229, 9, 20, 0.2)', - justifyContent: 'center', - alignItems: 'center', - }, - resumeTextContainer: { - flex: 1, - }, - resumeTitle: { - color: 'white', - fontSize: 20, - fontWeight: 'bold', - marginBottom: 4, - }, - resumeInfo: { - color: 'rgba(255, 255, 255, 0.9)', - fontSize: 14, - }, - resumeProgressContainer: { - marginTop: 12, - }, - resumeProgressBar: { - height: 4, - backgroundColor: 'rgba(255, 255, 255, 0.2)', - borderRadius: 2, - overflow: 'hidden', - marginBottom: 6, - }, - resumeProgressFill: { - height: '100%', - backgroundColor: '#E50914', - }, - resumeTimeText: { - color: 'rgba(255,255,255,0.7)', - fontSize: 12, - }, - resumeButtons: { - flexDirection: 'row', - justifyContent: 'flex-end', - width: '100%', - gap: 12, - }, - resumeButton: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 10, - paddingHorizontal: 16, - borderRadius: 6, - backgroundColor: 'rgba(255, 255, 255, 0.15)', - minWidth: 110, - justifyContent: 'center', - }, - buttonIcon: { - marginRight: 6, - }, - resumeButtonText: { - color: 'white', - fontWeight: 'bold', - fontSize: 14, - }, - resumeFromButton: { - backgroundColor: '#E50914', - }, - rememberChoiceContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - marginBottom: 16, - paddingHorizontal: 2, - }, - checkboxContainer: { - flexDirection: 'row', - alignItems: 'center', - }, - checkbox: { - width: 18, - height: 18, - borderRadius: 3, - borderWidth: 2, - borderColor: 'rgba(255, 255, 255, 0.5)', - marginRight: 8, - justifyContent: 'center', - alignItems: 'center', - }, - checkboxChecked: { - backgroundColor: '#E50914', - borderColor: '#E50914', - }, - rememberChoiceText: { - color: 'rgba(255, 255, 255, 0.8)', - fontSize: 14, - }, - resetPreferenceButton: { - padding: 4, - }, - resetPreferenceText: { - color: '#E50914', - fontSize: 12, - fontWeight: 'bold', - }, - openingOverlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(0,0,0,0.85)', - justifyContent: 'center', - alignItems: 'center', - zIndex: 2000, - margin: 0, - padding: 0, - }, - openingContent: { - padding: 20, - backgroundColor: 'rgba(0,0,0,0.85)', - borderRadius: 10, - justifyContent: 'center', - alignItems: 'center', - }, - openingText: { - color: 'white', - fontSize: 18, - fontWeight: 'bold', - marginTop: 20, - }, - videoPlayerContainer: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - margin: 0, - padding: 0, - }, - subtitleSizeContainer: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: 10, - paddingVertical: 12, - marginBottom: 8, - backgroundColor: 'rgba(255, 255, 255, 0.05)', - borderRadius: 6, - }, - subtitleSizeLabel: { - color: 'white', - fontSize: 14, - fontWeight: 'bold', - }, - subtitleSizeControls: { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - }, - sizeButton: { - width: 30, - height: 30, - borderRadius: 15, - backgroundColor: 'rgba(255, 255, 255, 0.2)', - justifyContent: 'center', - alignItems: 'center', - }, - subtitleSizeText: { - color: 'white', - fontSize: 14, - fontWeight: 'bold', - minWidth: 40, - textAlign: 'center', - }, - customSubtitleContainer: { - position: 'absolute', - bottom: 40, // Position above controls and progress bar - left: 20, - right: 20, - alignItems: 'center', - zIndex: 1500, // Higher z-index to appear above other elements - }, - customSubtitleWrapper: { - backgroundColor: 'rgba(0, 0, 0, 0.7)', - padding: 10, - borderRadius: 5, - }, - customSubtitleText: { - color: 'white', - textAlign: 'center', - textShadowColor: 'rgba(0, 0, 0, 0.9)', - textShadowOffset: { width: 2, height: 2 }, - textShadowRadius: 4, - lineHeight: undefined, // Let React Native calculate line height - fontWeight: '500', - }, - loadSubtitlesButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - padding: 12, - marginTop: 8, - borderRadius: 6, - backgroundColor: 'rgba(229, 9, 20, 0.2)', - borderWidth: 1, - borderColor: '#E50914', - }, - loadSubtitlesText: { - color: '#E50914', - fontSize: 14, - fontWeight: 'bold', - marginLeft: 8, - }, - disabledContainer: { - opacity: 0.5, - }, - disabledText: { - color: '#666', - }, - disabledButton: { - backgroundColor: '#666', - }, - noteContainer: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 10, - }, - noteText: { - color: '#aaa', - fontSize: 12, - marginLeft: 5, - }, - subtitleLanguageItem: { - flexDirection: 'row', - alignItems: 'center', - flex: 1, - }, - flagIcon: { - width: 24, - height: 18, - marginRight: 12, - borderRadius: 2, - }, - modernModalContainer: { - width: '90%', - maxWidth: 500, - backgroundColor: '#181818', - borderRadius: 10, - overflow: 'hidden', - shadowColor: '#000', - shadowOffset: { width: 0, height: 6 }, - shadowOpacity: 0.4, - shadowRadius: 10, - elevation: 8, - }, - modernModalHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 12, - borderBottomWidth: 1, - borderBottomColor: '#333', - }, - modernModalTitle: { - color: 'white', - fontSize: 18, - fontWeight: 'bold', - }, - modernCloseButton: { - padding: 4, - }, - modernTrackListScrollContainer: { - maxHeight: 350, - }, - modernTrackListContainer: { - padding: 6, - }, - sectionContainer: { - marginBottom: 20, - }, - sectionTitle: { - color: 'white', - fontSize: 16, - fontWeight: 'bold', - marginBottom: 8, - }, - sectionDescription: { - color: 'rgba(255, 255, 255, 0.7)', - fontSize: 12, - marginBottom: 12, - }, - trackIconContainer: { - width: 24, - height: 24, - borderRadius: 12, - backgroundColor: 'rgba(255, 255, 255, 0.1)', - justifyContent: 'center', - alignItems: 'center', - }, - modernTrackInfoContainer: { - flex: 1, - marginLeft: 10, - }, - modernTrackPrimaryText: { - color: 'white', - fontSize: 14, - fontWeight: '500', - }, - modernTrackSecondaryText: { - color: '#aaa', - fontSize: 11, - marginTop: 2, - }, - modernSelectedIndicator: { - width: 24, - height: 24, - borderRadius: 12, - backgroundColor: 'rgba(255, 255, 255, 0.15)', - justifyContent: 'center', - alignItems: 'center', - }, - modernEmptyStateContainer: { - alignItems: 'center', - justifyContent: 'center', - padding: 20, - }, - modernEmptyStateText: { - color: '#888', - fontSize: 14, - marginTop: 8, - textAlign: 'center', - }, - searchSubtitlesButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - padding: 12, - marginTop: 8, - borderRadius: 6, - backgroundColor: 'rgba(229, 9, 20, 0.2)', - borderWidth: 1, - borderColor: '#E50914', - }, - searchButtonContent: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 8, - }, - searchSubtitlesText: { - color: '#E50914', - fontSize: 14, - fontWeight: 'bold', - marginLeft: 8, - }, - modernSubtitleSizeContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - }, - modernSizeButton: { - width: 30, - height: 30, - borderRadius: 15, - backgroundColor: 'rgba(255, 255, 255, 0.2)', - justifyContent: 'center', - alignItems: 'center', - }, - modernTrackItem: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: 12, - marginVertical: 4, - borderRadius: 8, - backgroundColor: '#222', - }, - modernSelectedTrackItem: { - backgroundColor: 'rgba(76, 175, 80, 0.15)', - borderWidth: 1, - borderColor: 'rgba(76, 175, 80, 0.3)', - }, - sizeDisplayContainer: { - alignItems: 'center', - flex: 1, - marginHorizontal: 20, - }, - modernSubtitleSizeText: { - color: 'white', - fontSize: 16, - fontWeight: 'bold', - }, - sizeLabel: { - color: 'rgba(255, 255, 255, 0.7)', - fontSize: 12, - marginTop: 2, - }, -}); - -export default VideoPlayer; \ No newline at end of file