From 4a94e6248db2584945604efe8e1bd86505168ef3 Mon Sep 17 00:00:00 2001 From: tapframe Date: Mon, 9 Jun 2025 00:44:00 +0530 Subject: [PATCH] Enhance MetadataScreen with improved loading transitions and content visibility. Introduce state management for smooth transitions between loading and content display, utilizing animated styles for opacity and scaling effects. Refactor HeroSection integration to support new animation properties, enhancing the overall user experience during content loading. --- eas.json | 3 +- .../loading/MetadataLoadingScreen.tsx | 305 ++ src/components/metadata/HeroSection.tsx | 128 +- src/components/player/VideoPlayer.tsx | 900 +++++ .../player/controls/PlayerControls.tsx | 217 ++ .../player/modals/AudioTrackModal.tsx | 75 + .../player/modals/ResumeOverlay.tsx | 115 + .../player/modals/SubtitleModals.tsx | 281 ++ .../player/subtitles/CustomSubtitles.tsx | 29 + src/components/player/utils/playerStyles.ts | 755 +++++ src/components/player/utils/playerTypes.ts | 88 + src/components/player/utils/playerUtils.ts | 219 ++ src/hooks/useMetadataAnimations.ts | 361 +- src/navigation/AppNavigator.tsx | 517 +-- src/screens/MetadataScreen.tsx | 299 +- src/screens/VideoPlayer.tsx | 2939 ----------------- 16 files changed, 3804 insertions(+), 3427 deletions(-) create mode 100644 src/components/loading/MetadataLoadingScreen.tsx create mode 100644 src/components/player/VideoPlayer.tsx create mode 100644 src/components/player/controls/PlayerControls.tsx create mode 100644 src/components/player/modals/AudioTrackModal.tsx create mode 100644 src/components/player/modals/ResumeOverlay.tsx create mode 100644 src/components/player/modals/SubtitleModals.tsx create mode 100644 src/components/player/subtitles/CustomSubtitles.tsx create mode 100644 src/components/player/utils/playerStyles.ts create mode 100644 src/components/player/utils/playerTypes.ts create mode 100644 src/components/player/utils/playerUtils.ts delete mode 100644 src/screens/VideoPlayer.tsx 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