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.

This commit is contained in:
tapframe 2025-06-09 00:44:00 +05:30
parent a37d4f5a8b
commit 4a94e6248d
16 changed files with 3804 additions and 3427 deletions

View file

@ -12,7 +12,8 @@
"distribution": "internal"
},
"production": {
"autoIncrement": true
"autoIncrement": true,
"extends": "apk"
},
"release": {
"distribution": "store",

View file

@ -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<MetadataLoadingScreenProps> = ({
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;
}) => (
<View style={[
{
width: elementWidth,
height: elementHeight,
borderRadius,
marginBottom,
backgroundColor: currentTheme.colors.card,
overflow: 'hidden',
},
style
]}>
<Animated.View style={[
StyleSheet.absoluteFill,
{
opacity: pulseAnim,
backgroundColor: currentTheme.colors.primary + '20',
}
]} />
<Animated.View style={[
StyleSheet.absoluteFill,
{
transform: [{ translateX: shimmerTranslateX }],
}
]}>
<LinearGradient
colors={[
'transparent',
currentTheme.colors.white + '20',
'transparent'
]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
/>
</Animated.View>
</View>
);
return (
<SafeAreaView
style={[styles.container, {
backgroundColor: currentTheme.colors.darkBackground,
}]}
edges={['bottom']}
>
<StatusBar
translucent={true}
backgroundColor="transparent"
barStyle="light-content"
/>
<Animated.View style={[
styles.content,
{ opacity: fadeAnim }
]}>
{/* Hero Skeleton */}
<View style={styles.heroSection}>
<SkeletonElement
width="100%"
height={height * 0.6}
borderRadius={0}
marginBottom={0}
/>
{/* Overlay content on hero */}
<View style={styles.heroOverlay}>
<LinearGradient
colors={[
'transparent',
'rgba(0,0,0,0.4)',
'rgba(0,0,0,0.8)',
currentTheme.colors.darkBackground,
]}
style={StyleSheet.absoluteFill}
/>
{/* Bottom hero content skeleton */}
<View style={styles.heroBottomContent}>
<SkeletonElement width="60%" height={32} borderRadius={16} />
<SkeletonElement width="40%" height={20} borderRadius={10} />
<View style={styles.genresRow}>
<SkeletonElement width={80} height={24} borderRadius={12} marginBottom={0} style={{ marginRight: 8 }} />
<SkeletonElement width={90} height={24} borderRadius={12} marginBottom={0} style={{ marginRight: 8 }} />
<SkeletonElement width={70} height={24} borderRadius={12} marginBottom={0} />
</View>
<View style={styles.buttonsRow}>
<SkeletonElement width={120} height={44} borderRadius={22} marginBottom={0} style={{ marginRight: 12 }} />
<SkeletonElement width={100} height={44} borderRadius={22} marginBottom={0} />
</View>
</View>
</View>
</View>
{/* Content Section Skeletons */}
<View style={styles.contentSection}>
{/* Synopsis skeleton */}
<View style={styles.synopsisSection}>
<SkeletonElement width="30%" height={24} borderRadius={12} />
<SkeletonElement width="100%" height={16} borderRadius={8} />
<SkeletonElement width="95%" height={16} borderRadius={8} />
<SkeletonElement width="80%" height={16} borderRadius={8} />
</View>
{/* Cast section skeleton */}
<View style={styles.castSection}>
<SkeletonElement width="20%" height={24} borderRadius={12} />
<View style={styles.castRow}>
{[1, 2, 3, 4].map((item) => (
<View key={item} style={styles.castItem}>
<SkeletonElement width={80} height={80} borderRadius={40} marginBottom={8} />
<SkeletonElement width={60} height={12} borderRadius={6} marginBottom={4} />
<SkeletonElement width={70} height={10} borderRadius={5} marginBottom={0} />
</View>
))}
</View>
</View>
{/* Episodes/Details skeleton based on type */}
{type === 'series' ? (
<View style={styles.episodesSection}>
<SkeletonElement width="25%" height={24} borderRadius={12} />
<SkeletonElement width={150} height={36} borderRadius={18} />
{[1, 2, 3].map((item) => (
<View key={item} style={styles.episodeItem}>
<SkeletonElement width={120} height={68} borderRadius={8} marginBottom={0} style={{ marginRight: 12 }} />
<View style={styles.episodeInfo}>
<SkeletonElement width="80%" height={16} borderRadius={8} />
<SkeletonElement width="60%" height={14} borderRadius={7} />
<SkeletonElement width="90%" height={12} borderRadius={6} />
</View>
</View>
))}
</View>
) : (
<View style={styles.detailsSection}>
<SkeletonElement width="25%" height={24} borderRadius={12} />
<View style={styles.detailsGrid}>
<SkeletonElement width="48%" height={60} borderRadius={8} />
<SkeletonElement width="48%" height={60} borderRadius={8} />
</View>
</View>
)}
</View>
</Animated.View>
</SafeAreaView>
);
};
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;

View file

@ -31,14 +31,19 @@ interface HeroSectionProps {
heroHeight: Animated.SharedValue<number>;
heroOpacity: Animated.SharedValue<number>;
heroScale: Animated.SharedValue<number>;
heroRotate: Animated.SharedValue<number>;
logoOpacity: Animated.SharedValue<number>;
logoScale: Animated.SharedValue<number>;
logoRotate: Animated.SharedValue<number>;
genresOpacity: Animated.SharedValue<number>;
genresTranslateY: Animated.SharedValue<number>;
genresScale: Animated.SharedValue<number>;
buttonsOpacity: Animated.SharedValue<number>;
buttonsTranslateY: Animated.SharedValue<number>;
buttonsScale: Animated.SharedValue<number>;
watchProgressOpacity: Animated.SharedValue<number>;
watchProgressScaleY: Animated.SharedValue<number>;
watchProgressWidth: Animated.SharedValue<number>;
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 (
<Animated.View style={[styles.watchProgressContainer, animatedStyle]}>
<View style={styles.watchProgressBar}>
<View
<Animated.View
style={[
styles.watchProgressFill,
progressBarStyle,
{
width: `${progressPercent}%`,
backgroundColor: currentTheme.colors.primary
@ -225,14 +233,19 @@ const HeroSection: React.FC<HeroSectionProps> = ({
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<HeroSectionProps> = ({
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<HeroSectionProps> = ({
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<HeroSectionProps> = ({
translateY: interpolate(
dampedScrollY.value,
[0, 100, 300],
[0, -30, -80],
[0, -35, -90],
Extrapolate.CLAMP
)
},
@ -303,9 +371,17 @@ const HeroSection: React.FC<HeroSectionProps> = ({
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<HeroSectionProps> = ({
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',

View file

@ -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<RouteProp<RootStackParamList, 'Player'>>();
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<AudioTrack[]>([]);
const [selectedAudioTrack, setSelectedAudioTrack] = useState<number | null>(null);
const [textTracks, setTextTracks] = useState<TextTrack[]>([]);
const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1);
const [resizeMode, setResizeMode] = useState<ResizeModeType>('stretch');
const [buffered, setBuffered] = useState(0);
const vlcRef = useRef<any>(null);
const [showAudioModal, setShowAudioModal] = useState(false);
const [showSubtitleModal, setShowSubtitleModal] = useState(false);
const [initialPosition, setInitialPosition] = useState<number | null>(null);
const [progressSaveInterval, setProgressSaveInterval] = useState<NodeJS.Timeout | null>(null);
const [isInitialSeekComplete, setIsInitialSeekComplete] = useState(false);
const [showResumeOverlay, setShowResumeOverlay] = useState(false);
const [resumePosition, setResumePosition] = useState<number | null>(null);
const [rememberChoice, setRememberChoice] = useState(false);
const [resumePreference, setResumePreference] = useState<string | null>(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<Array<{id: number, name: string, language?: string}>>([]);
const [vlcTextTracks, setVlcTextTracks] = useState<Array<{id: number, name: string, language?: string}>>([]);
const [isPlayerReady, setIsPlayerReady] = useState(false);
const progressAnim = useRef(new Animated.Value(0)).current;
const progressBarRef = useRef<View>(null);
const [isDragging, setIsDragging] = useState(false);
const seekDebounceTimer = useRef<NodeJS.Timeout | null>(null);
const pendingSeekValue = useRef<number | null>(null);
const lastSeekTime = useRef<number>(0);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [videoAspectRatio, setVideoAspectRatio] = useState<number | null>(null);
const [is16by9Content, setIs16by9Content] = useState(false);
const [customVideoStyles, setCustomVideoStyles] = useState<any>({});
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<PinchGestureHandler>(null);
const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]);
const [currentSubtitle, setCurrentSubtitle] = useState<string>('');
const [subtitleSize, setSubtitleSize] = useState<number>(DEFAULT_SUBTITLE_SIZE);
const [useCustomSubtitles, setUseCustomSubtitles] = useState<boolean>(false);
const [isLoadingSubtitles, setIsLoadingSubtitles] = useState<boolean>(false);
const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]);
const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState<boolean>(false);
const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState<boolean>(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 (
<View style={[styles.container, {
width: screenDimensions.width,
height: screenDimensions.height,
position: 'absolute',
top: 0,
left: 0,
}]}>
<Animated.View
style={[
styles.openingOverlay,
{
opacity: backgroundFadeAnim,
zIndex: isOpeningAnimationComplete ? -1 : 3000,
width: screenDimensions.width,
height: screenDimensions.height,
}
]}
pointerEvents={isOpeningAnimationComplete ? 'none' : 'auto'}
>
<View style={styles.openingContent}>
<ActivityIndicator size="large" color="#E50914" />
<Text style={styles.openingText}>Loading video...</Text>
</View>
</Animated.View>
<Animated.View
style={[
styles.videoPlayerContainer,
{
opacity: openingFadeAnim,
transform: isOpeningAnimationComplete ? [] : [{ scale: openingScaleAnim }],
width: screenDimensions.width,
height: screenDimensions.height,
}
]}
>
<TouchableOpacity
style={[styles.videoContainer, {
width: screenDimensions.width,
height: screenDimensions.height,
}]}
onPress={toggleControls}
activeOpacity={1}
>
<PinchGestureHandler
ref={pinchRef}
onGestureEvent={onPinchGestureEvent}
onHandlerStateChange={onPinchHandlerStateChange}
>
<View style={{
position: 'absolute',
top: 0,
left: 0,
width: screenDimensions.width,
height: screenDimensions.height,
backgroundColor: '#000',
}}>
<TouchableOpacity
style={{ flex: 1 }}
activeOpacity={1}
onPress={toggleControls}
onLongPress={resetZoom}
delayLongPress={300}
>
<VLCPlayer
ref={vlcRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: screenDimensions.width,
height: screenDimensions.height,
transform: [
{ scale: zoomScale },
],
}}
source={{
uri: uri,
initOptions: [
'--rtsp-tcp',
'--network-caching=150',
'--rtsp-caching=150',
'--no-audio-time-stretch',
'--clock-jitter=0',
'--clock-synchro=0',
'--drop-late-frames',
'--skip-frames',
],
}}
paused={paused}
autoplay={true}
autoAspectRatio={false}
resizeMode={'stretch' as any}
audioTrack={selectedAudioTrack || undefined}
textTrack={selectedTextTrack === -1 ? undefined : selectedTextTrack}
onLoad={onLoad}
onProgress={handleProgress}
onEnd={onEnd}
onError={handleError}
/>
</TouchableOpacity>
</View>
</PinchGestureHandler>
<PlayerControls
showControls={showControls}
fadeAnim={fadeAnim}
paused={paused}
title={title}
episodeTitle={episodeTitle}
season={season}
episode={episode}
quality={quality}
year={year}
streamProvider={streamProvider}
currentTime={currentTime}
duration={duration}
playbackSpeed={playbackSpeed}
zoomScale={zoomScale}
vlcAudioTracks={vlcAudioTracks}
selectedAudioTrack={selectedAudioTrack}
togglePlayback={togglePlayback}
skip={skip}
handleClose={handleClose}
cycleAspectRatio={cycleAspectRatio}
setShowAudioModal={setShowAudioModal}
setShowSubtitleModal={setShowSubtitleModal}
progressBarRef={progressBarRef}
progressAnim={progressAnim}
handleProgressBarTouch={handleProgressBarTouch}
handleProgressBarDragStart={handleProgressBarDragStart}
handleProgressBarDragMove={handleProgressBarDragMove}
handleProgressBarDragEnd={handleProgressBarDragEnd}
buffered={buffered}
formatTime={formatTime}
/>
<CustomSubtitles
useCustomSubtitles={useCustomSubtitles}
currentSubtitle={currentSubtitle}
subtitleSize={subtitleSize}
/>
<ResumeOverlay
showResumeOverlay={showResumeOverlay}
resumePosition={resumePosition}
duration={duration}
title={title}
season={season}
episode={episode}
rememberChoice={rememberChoice}
setRememberChoice={setRememberChoice}
resumePreference={resumePreference}
resetResumePreference={resetResumePreference}
handleResume={handleResume}
handleStartFromBeginning={handleStartFromBeginning}
/>
</TouchableOpacity>
</Animated.View>
<AudioTrackModal
showAudioModal={showAudioModal}
setShowAudioModal={setShowAudioModal}
vlcAudioTracks={vlcAudioTracks}
selectedAudioTrack={selectedAudioTrack}
selectAudioTrack={selectAudioTrack}
/>
<SubtitleModals
showSubtitleModal={showSubtitleModal}
setShowSubtitleModal={setShowSubtitleModal}
showSubtitleLanguageModal={showSubtitleLanguageModal}
setShowSubtitleLanguageModal={setShowSubtitleLanguageModal}
isLoadingSubtitleList={isLoadingSubtitleList}
isLoadingSubtitles={isLoadingSubtitles}
customSubtitles={customSubtitles}
availableSubtitles={availableSubtitles}
vlcTextTracks={vlcTextTracks}
selectedTextTrack={selectedTextTrack}
useCustomSubtitles={useCustomSubtitles}
subtitleSize={subtitleSize}
fetchAvailableSubtitles={fetchAvailableSubtitles}
loadWyzieSubtitle={loadWyzieSubtitle}
selectTextTrack={selectTextTrack}
increaseSubtitleSize={increaseSubtitleSize}
decreaseSubtitleSize={decreaseSubtitleSize}
/>
</View>
);
};
export default VideoPlayer;

View file

@ -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<View>;
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<PlayerControlsProps> = ({
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 (
<Animated.View
style={[StyleSheet.absoluteFill, { opacity: fadeAnim }]}
pointerEvents={showControls ? 'auto' : 'none'}
>
{/* Progress bar with enhanced touch handling */}
<View style={styles.sliderContainer}>
<View
style={styles.progressTouchArea}
onTouchStart={handleProgressBarDragStart}
onTouchMove={handleProgressBarDragMove}
onTouchEnd={handleProgressBarDragEnd}
>
<TouchableOpacity
activeOpacity={0.8}
onPress={handleProgressBarTouch}
style={{width: '100%'}}
>
<View
ref={progressBarRef}
style={styles.progressBarContainer}
>
{/* Buffered Progress */}
<View style={[styles.bufferProgress, {
width: `${(buffered / (duration || 1)) * 100}%`
}]} />
{/* Animated Progress */}
<Animated.View
style={[
styles.progressBarFill,
{
width: progressAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0%', '100%']
})
}
]}
/>
</View>
</TouchableOpacity>
</View>
<View style={styles.timeDisplay}>
<Text style={styles.duration}>{formatTime(currentTime)}</Text>
<Text style={styles.duration}>{formatTime(duration)}</Text>
</View>
</View>
{/* Controls Overlay */}
<View style={styles.controlsContainer}>
{/* Top Gradient & Header */}
<LinearGradient
colors={['rgba(0,0,0,0.7)', 'transparent']}
style={styles.topGradient}
>
<View style={styles.header}>
{/* Title Section - Enhanced with metadata */}
<View style={styles.titleSection}>
<Text style={styles.title}>{title}</Text>
{/* Show season and episode for series */}
{season && episode && (
<Text style={styles.episodeInfo}>
S{season}E{episode} {episodeTitle && `${episodeTitle}`}
</Text>
)}
{/* Show year, quality, and provider */}
<View style={styles.metadataRow}>
{year && <Text style={styles.metadataText}>{year}</Text>}
{quality && <View style={styles.qualityBadge}><Text style={styles.qualityText}>{quality}</Text></View>}
{streamProvider && <Text style={styles.providerText}>via {streamProvider}</Text>}
</View>
</View>
<TouchableOpacity style={styles.closeButton} onPress={handleClose}>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
</LinearGradient>
{/* Center Controls (Play/Pause, Skip) */}
<View style={styles.controls}>
<TouchableOpacity onPress={() => skip(-10)} style={styles.skipButton}>
<Ionicons name="play-back" size={24} color="white" />
<Text style={styles.skipText}>10</Text>
</TouchableOpacity>
<TouchableOpacity onPress={togglePlayback} style={styles.playButton}>
<Ionicons name={paused ? "play" : "pause"} size={40} color="white" />
</TouchableOpacity>
<TouchableOpacity onPress={() => skip(10)} style={styles.skipButton}>
<Ionicons name="play-forward" size={24} color="white" />
<Text style={styles.skipText}>10</Text>
</TouchableOpacity>
</View>
{/* Bottom Gradient */}
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.7)']}
style={styles.bottomGradient}
>
<View style={styles.bottomControls}>
{/* Bottom Buttons Row */}
<View style={styles.bottomButtons}>
{/* Speed Button */}
<TouchableOpacity style={styles.bottomButton}>
<Ionicons name="speedometer" size={20} color="white" />
<Text style={styles.bottomButtonText}>Speed ({playbackSpeed}x)</Text>
</TouchableOpacity>
{/* Fill/Cover Button - Updated to show fill/cover modes */}
<TouchableOpacity style={styles.bottomButton} onPress={cycleAspectRatio}>
<Ionicons name="resize" size={20} color="white" />
<Text style={[styles.bottomButtonText, { fontSize: 14, textAlign: 'center' }]}>
{zoomScale === 1.1 ? 'Fill' : 'Cover'}
</Text>
</TouchableOpacity>
{/* Audio Button - Updated to use vlcAudioTracks */}
<TouchableOpacity
style={styles.bottomButton}
onPress={() => setShowAudioModal(true)}
disabled={vlcAudioTracks.length <= 1}
>
<Ionicons name="volume-high" size={20} color={vlcAudioTracks.length <= 1 ? 'grey' : 'white'} />
<Text style={[styles.bottomButtonText, vlcAudioTracks.length <= 1 && {color: 'grey'}]}>
{`Audio: ${getTrackDisplayName(vlcAudioTracks.find(t => t.id === selectedAudioTrack) || {id: -1, name: 'Default'})}`}
</Text>
</TouchableOpacity>
{/* Subtitle Button - Always available for external subtitle search */}
<TouchableOpacity
style={styles.bottomButton}
onPress={() => setShowSubtitleModal(true)}
>
<Ionicons name="text" size={20} color="white" />
<Text style={styles.bottomButtonText}>
Subtitles
</Text>
</TouchableOpacity>
</View>
</View>
</LinearGradient>
</View>
</Animated.View>
);
};
export default PlayerControls;

View file

@ -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<AudioTrackModalProps> = ({
showAudioModal,
setShowAudioModal,
vlcAudioTracks,
selectedAudioTrack,
selectAudioTrack,
}) => {
if (!showAudioModal) return null;
return (
<View style={styles.fullscreenOverlay}>
<View style={styles.enhancedModalContainer}>
<View style={styles.enhancedModalHeader}>
<Text style={styles.enhancedModalTitle}>Audio</Text>
<TouchableOpacity
style={styles.enhancedCloseButton}
onPress={() => setShowAudioModal(false)}
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<ScrollView style={styles.trackListScrollContainer}>
<View style={styles.trackListContainer}>
{vlcAudioTracks.length > 0 ? vlcAudioTracks.map(track => (
<TouchableOpacity
key={track.id}
style={styles.enhancedTrackItem}
onPress={() => {
selectAudioTrack(track.id);
setShowAudioModal(false);
}}
>
<View style={styles.trackInfoContainer}>
<Text style={styles.trackPrimaryText}>
{getTrackDisplayName(track)}
</Text>
{(track.name && track.language) && (
<Text style={styles.trackSecondaryText}>{track.name}</Text>
)}
</View>
{selectedAudioTrack === track.id && (
<View style={styles.selectedIndicatorContainer}>
<Ionicons name="checkmark" size={22} color="#E50914" />
</View>
)}
</TouchableOpacity>
)) : (
<View style={styles.emptyStateContainer}>
<Ionicons name="alert-circle-outline" size={40} color="#888" />
<Text style={styles.emptyStateText}>No audio tracks available</Text>
</View>
)}
</View>
</ScrollView>
</View>
</View>
);
};
export default AudioTrackModal;

View file

@ -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<ResumeOverlayProps> = ({
showResumeOverlay,
resumePosition,
duration,
title,
season,
episode,
rememberChoice,
setRememberChoice,
resumePreference,
resetResumePreference,
handleResume,
handleStartFromBeginning,
}) => {
if (!showResumeOverlay || resumePosition === null) return null;
return (
<View style={styles.resumeOverlay}>
<LinearGradient
colors={['rgba(0,0,0,0.9)', 'rgba(0,0,0,0.7)']}
style={styles.resumeContainer}
>
<View style={styles.resumeContent}>
<View style={styles.resumeIconContainer}>
<Ionicons name="play-circle" size={40} color="#E50914" />
</View>
<View style={styles.resumeTextContainer}>
<Text style={styles.resumeTitle}>Continue Watching</Text>
<Text style={styles.resumeInfo}>
{title}
{season && episode && ` • S${season}E${episode}`}
</Text>
<View style={styles.resumeProgressContainer}>
<View style={styles.resumeProgressBar}>
<View
style={[
styles.resumeProgressFill,
{ width: `${duration > 0 ? (resumePosition / duration) * 100 : 0}%` }
]}
/>
</View>
<Text style={styles.resumeTimeText}>
{formatTime(resumePosition)} {duration > 0 ? `/ ${formatTime(duration)}` : ''}
</Text>
</View>
</View>
</View>
{/* Remember choice checkbox */}
<TouchableOpacity
style={styles.rememberChoiceContainer}
onPress={() => setRememberChoice(!rememberChoice)}
activeOpacity={0.7}
>
<View style={styles.checkboxContainer}>
<View style={[styles.checkbox, rememberChoice && styles.checkboxChecked]}>
{rememberChoice && <Ionicons name="checkmark" size={12} color="white" />}
</View>
<Text style={styles.rememberChoiceText}>Remember my choice</Text>
</View>
{resumePreference && (
<TouchableOpacity
onPress={resetResumePreference}
style={styles.resetPreferenceButton}
>
<Text style={styles.resetPreferenceText}>Reset</Text>
</TouchableOpacity>
)}
</TouchableOpacity>
<View style={styles.resumeButtons}>
<TouchableOpacity
style={styles.resumeButton}
onPress={handleStartFromBeginning}
>
<Ionicons name="refresh" size={16} color="white" style={styles.buttonIcon} />
<Text style={styles.resumeButtonText}>Start Over</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.resumeButton, styles.resumeFromButton]}
onPress={handleResume}
>
<Ionicons name="play" size={16} color="white" style={styles.buttonIcon} />
<Text style={styles.resumeButtonText}>Resume</Text>
</TouchableOpacity>
</View>
</LinearGradient>
</View>
);
};
export default ResumeOverlay;

View file

@ -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<SubtitleModalsProps> = ({
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 (
<View style={styles.fullscreenOverlay}>
<View style={styles.modernModalContainer}>
<View style={styles.modernModalHeader}>
<Text style={styles.modernModalTitle}>Subtitle Settings</Text>
<TouchableOpacity
style={styles.modernCloseButton}
onPress={() => setShowSubtitleModal(false)}
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<ScrollView style={styles.modernTrackListScrollContainer} showsVerticalScrollIndicator={false}>
<View style={styles.modernTrackListContainer}>
{/* External Subtitles Section - Priority */}
<View style={styles.sectionContainer}>
<Text style={styles.sectionTitle}>External Subtitles</Text>
<Text style={styles.sectionDescription}>High quality subtitles with size control</Text>
{/* Custom subtitles option - show if loaded */}
{customSubtitles.length > 0 ? (
<TouchableOpacity
style={[styles.modernTrackItem, useCustomSubtitles && styles.modernSelectedTrackItem]}
onPress={() => {
selectTextTrack(-999);
setShowSubtitleModal(false);
}}
>
<View style={styles.trackIconContainer}>
<Ionicons name="document-text" size={20} color="#4CAF50" />
</View>
<View style={styles.modernTrackInfoContainer}>
<Text style={styles.modernTrackPrimaryText}>Custom Subtitles</Text>
<Text style={styles.modernTrackSecondaryText}>
{customSubtitles.length} cues Size adjustable
</Text>
</View>
{useCustomSubtitles && (
<View style={styles.modernSelectedIndicator}>
<Ionicons name="checkmark-circle" size={24} color="#4CAF50" />
</View>
)}
</TouchableOpacity>
) : null}
{/* Search for external subtitles */}
<TouchableOpacity
style={styles.searchSubtitlesButton}
onPress={() => {
setShowSubtitleModal(false);
fetchAvailableSubtitles();
}}
disabled={isLoadingSubtitleList}
>
<View style={styles.searchButtonContent}>
{isLoadingSubtitleList ? (
<ActivityIndicator size="small" color="#2196F3" />
) : (
<Ionicons name="search" size={20} color="#2196F3" />
)}
<Text style={styles.searchSubtitlesText}>
{isLoadingSubtitleList ? 'Searching...' : 'Search Online Subtitles'}
</Text>
</View>
</TouchableOpacity>
</View>
{/* Subtitle Size Controls - Only for custom subtitles */}
{useCustomSubtitles && (
<View style={styles.sectionContainer}>
<Text style={styles.sectionTitle}>Size Control</Text>
<View style={styles.modernSubtitleSizeContainer}>
<TouchableOpacity
style={styles.modernSizeButton}
onPress={decreaseSubtitleSize}
>
<Ionicons name="remove" size={20} color="white" />
</TouchableOpacity>
<View style={styles.sizeDisplayContainer}>
<Text style={styles.modernSubtitleSizeText}>{subtitleSize}px</Text>
<Text style={styles.sizeLabel}>Font Size</Text>
</View>
<TouchableOpacity
style={styles.modernSizeButton}
onPress={increaseSubtitleSize}
>
<Ionicons name="add" size={20} color="white" />
</TouchableOpacity>
</View>
</View>
)}
{/* Built-in Subtitles Section */}
<View style={styles.sectionContainer}>
<Text style={styles.sectionTitle}>Built-in Subtitles</Text>
<Text style={styles.sectionDescription}>System default sizing No customization</Text>
{/* Off option */}
<TouchableOpacity
style={[styles.modernTrackItem, (selectedTextTrack === -1 && !useCustomSubtitles) && styles.modernSelectedTrackItem]}
onPress={() => {
selectTextTrack(-1);
setShowSubtitleModal(false);
}}
>
<View style={styles.trackIconContainer}>
<Ionicons name="close-circle" size={20} color="#9E9E9E" />
</View>
<View style={styles.modernTrackInfoContainer}>
<Text style={styles.modernTrackPrimaryText}>Disabled</Text>
<Text style={styles.modernTrackSecondaryText}>No subtitles</Text>
</View>
{(selectedTextTrack === -1 && !useCustomSubtitles) && (
<View style={styles.modernSelectedIndicator}>
<Ionicons name="checkmark-circle" size={24} color="#9E9E9E" />
</View>
)}
</TouchableOpacity>
{/* Available built-in subtitle tracks */}
{vlcTextTracks.length > 0 ? vlcTextTracks.map(track => (
<TouchableOpacity
key={track.id}
style={[styles.modernTrackItem, (selectedTextTrack === track.id && !useCustomSubtitles) && styles.modernSelectedTrackItem]}
onPress={() => {
selectTextTrack(track.id);
setShowSubtitleModal(false);
}}
>
<View style={styles.trackIconContainer}>
<Ionicons name="text" size={20} color="#FF9800" />
</View>
<View style={styles.modernTrackInfoContainer}>
<Text style={styles.modernTrackPrimaryText}>
{getTrackDisplayName(track)}
</Text>
<Text style={styles.modernTrackSecondaryText}>
Built-in track System font size
</Text>
</View>
{(selectedTextTrack === track.id && !useCustomSubtitles) && (
<View style={styles.modernSelectedIndicator}>
<Ionicons name="checkmark-circle" size={24} color="#FF9800" />
</View>
)}
</TouchableOpacity>
)) : (
<View style={styles.modernEmptyStateContainer}>
<Ionicons name="information-circle-outline" size={24} color="#666" />
<Text style={styles.modernEmptyStateText}>No built-in subtitles available</Text>
</View>
)}
</View>
</View>
</ScrollView>
</View>
</View>
);
};
// Render subtitle language selection modal
const renderSubtitleLanguageModal = () => {
if (!showSubtitleLanguageModal) return null;
return (
<View style={styles.fullscreenOverlay}>
<View style={styles.enhancedModalContainer}>
<View style={styles.enhancedModalHeader}>
<Text style={styles.enhancedModalTitle}>Select Language</Text>
<TouchableOpacity
style={styles.enhancedCloseButton}
onPress={() => setShowSubtitleLanguageModal(false)}
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<ScrollView style={styles.trackListScrollContainer}>
<View style={styles.trackListContainer}>
{availableSubtitles.length > 0 ? availableSubtitles.map(subtitle => (
<TouchableOpacity
key={subtitle.id}
style={styles.enhancedTrackItem}
onPress={() => loadWyzieSubtitle(subtitle)}
disabled={isLoadingSubtitles}
>
<View style={styles.subtitleLanguageItem}>
<Image
source={{ uri: subtitle.flagUrl }}
style={styles.flagIcon}
resizeMode="cover"
/>
<View style={styles.trackInfoContainer}>
<Text style={styles.trackPrimaryText}>
{formatLanguage(subtitle.language)}
</Text>
<Text style={styles.trackSecondaryText}>
{subtitle.display}
</Text>
</View>
</View>
{isLoadingSubtitles && (
<ActivityIndicator size="small" color="#E50914" />
)}
</TouchableOpacity>
)) : (
<View style={styles.emptyStateContainer}>
<Ionicons name="alert-circle-outline" size={40} color="#888" />
<Text style={styles.emptyStateText}>
No subtitles found for this content
</Text>
</View>
)}
</View>
</ScrollView>
</View>
</View>
);
};
return (
<>
{renderSubtitleModal()}
{renderSubtitleLanguageModal()}
</>
);
};
export default SubtitleModals;

View file

@ -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<CustomSubtitlesProps> = ({
useCustomSubtitles,
currentSubtitle,
subtitleSize,
}) => {
if (!useCustomSubtitles || !currentSubtitle) return null;
return (
<View style={styles.customSubtitleContainer} pointerEvents="none">
<View style={styles.customSubtitleWrapper}>
<Text style={[styles.customSubtitleText, { fontSize: subtitleSize }]}>
{currentSubtitle}
</Text>
</View>
</View>
);
};
export default CustomSubtitles;

View file

@ -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,
},
});

View file

@ -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;
}

View file

@ -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;
};

View file

@ -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,

View file

@ -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 (
<SafeAreaProvider>
<StatusBar
@ -670,221 +679,307 @@ const AppNavigator = () => {
barStyle="light-content"
/>
<PaperProvider theme={CustomDarkTheme}>
<Stack.Navigator
screenOptions={{
headerShown: false,
// Disable animations for smoother transitions
animation: 'none',
// Ensure content is not popping in and out
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
}
}}
>
<Stack.Screen
name="MainTabs"
component={MainTabs as any}
/>
<Stack.Screen
name="Metadata"
component={MetadataScreen}
options={{ headerShown: false, animation: Platform.OS === 'ios' ? 'slide_from_right' : 'default' }}
/>
<Stack.Screen
name="Streams"
component={StreamsScreen as any}
options={{
headerShown: false,
animation: Platform.OS === 'ios' ? 'slide_from_bottom' : 'fade_from_bottom',
...(Platform.OS === 'ios' && { presentation: 'modal' }),
}}
/>
<Stack.Screen
name="Player"
component={VideoPlayer as any}
options={{ animation: Platform.OS === 'ios' ? 'slide_from_right' : 'default' }}
/>
<Stack.Screen
name="Catalog"
component={CatalogScreen as any}
options={{ animation: Platform.OS === 'ios' ? 'slide_from_right' : 'default' }}
/>
<Stack.Screen
name="Addons"
component={AddonsScreen as any}
options={{ animation: Platform.OS === 'ios' ? 'slide_from_right' : 'default' }}
/>
<Stack.Screen
name="Search"
component={SearchScreen as any}
options={{ animation: Platform.OS === 'ios' ? 'slide_from_right' : 'default' }}
/>
<Stack.Screen
name="CatalogSettings"
component={CatalogSettingsScreen as any}
options={{ animation: Platform.OS === 'ios' ? 'slide_from_right' : 'default' }}
/>
<Stack.Screen
name="HomeScreenSettings"
component={HomeScreenSettings}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
<View style={{
flex: 1,
backgroundColor: currentTheme.colors.darkBackground,
...(Platform.OS === 'android' && {
// Prevent white flashes on Android
opacity: 1,
})
}}>
<Stack.Navigator
screenOptions={{
headerShown: false,
// Use slide_from_right for consistency and smooth transitions
animation: Platform.OS === 'android' ? 'slide_from_right' : 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
// Ensure consistent background during transitions
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
// Improve Android performance with custom interpolator
...(Platform.OS === 'android' && {
cardStyleInterpolator: ({ current, layouts }: any) => {
return {
cardStyle: {
transform: [
{
translateX: current.progress.interpolate({
inputRange: [0, 1],
outputRange: [layouts.screen.width, 0],
}),
},
],
backgroundColor: currentTheme.colors.darkBackground,
},
};
},
}),
}}
/>
<Stack.Screen
name="HeroCatalogs"
component={HeroCatalogsScreen}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="ShowRatings"
component={ShowRatingsScreen}
options={{
animation: 'fade',
animationDuration: 200,
...(Platform.OS === 'ios' && { presentation: 'modal' }),
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: 'transparent',
},
}}
/>
<Stack.Screen
name="Calendar"
component={CalendarScreen as any}
options={{ animation: Platform.OS === 'ios' ? 'slide_from_right' : 'default' }}
/>
<Stack.Screen
name="NotificationSettings"
component={NotificationSettingsScreen as any}
options={{ animation: Platform.OS === 'ios' ? 'slide_from_right' : 'default' }}
/>
<Stack.Screen
name="MDBListSettings"
component={MDBListSettingsScreen}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="TMDBSettings"
component={TMDBSettingsScreen}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="TraktSettings"
component={TraktSettingsScreen}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="PlayerSettings"
component={PlayerSettingsScreen}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="LogoSourceSettings"
component={LogoSourceSettings}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="ThemeSettings"
component={ThemeScreen}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="ProfilesSettings"
component={ProfilesScreen}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
</Stack.Navigator>
>
<Stack.Screen
name="MainTabs"
component={MainTabs as any}
options={{
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="Metadata"
component={MetadataScreen}
options={{
headerShown: false,
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="Streams"
component={StreamsScreen as any}
options={{
headerShown: false,
animation: Platform.OS === 'ios' ? 'slide_from_bottom' : 'fade_from_bottom',
animationDuration: Platform.OS === 'android' ? 200 : 300,
...(Platform.OS === 'ios' && { presentation: 'modal' }),
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="Player"
component={VideoPlayer as any}
options={{
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 200 : 300,
contentStyle: {
backgroundColor: '#000000', // Pure black for video player
},
}}
/>
<Stack.Screen
name="Catalog"
component={CatalogScreen as any}
options={{
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="Addons"
component={AddonsScreen as any}
options={{
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="Search"
component={SearchScreen as any}
options={{
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="CatalogSettings"
component={CatalogSettingsScreen as any}
options={{
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="HomeScreenSettings"
component={HomeScreenSettings}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="HeroCatalogs"
component={HeroCatalogsScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="ShowRatings"
component={ShowRatingsScreen}
options={{
animation: Platform.OS === 'android' ? 'fade_from_bottom' : 'fade',
animationDuration: Platform.OS === 'android' ? 200 : 200,
...(Platform.OS === 'ios' && { presentation: 'modal' }),
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: 'transparent',
},
}}
/>
<Stack.Screen
name="Calendar"
component={CalendarScreen as any}
options={{
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="NotificationSettings"
component={NotificationSettingsScreen as any}
options={{
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="MDBListSettings"
component={MDBListSettingsScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="TMDBSettings"
component={TMDBSettingsScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="TraktSettings"
component={TraktSettingsScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="PlayerSettings"
component={PlayerSettingsScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="LogoSourceSettings"
component={LogoSourceSettings}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="ThemeSettings"
component={ThemeScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="ProfilesSettings"
component={ProfilesScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
</Stack.Navigator>
</View>
</PaperProvider>
</SafeAreaProvider>
);

View file

@ -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 (
<SafeAreaView
style={[styles.container, {
backgroundColor: currentTheme.colors.darkBackground
}]}
edges={['bottom']}
>
<StatusBar
translucent={true}
backgroundColor="transparent"
barStyle="light-content"
<Animated.View style={[StyleSheet.absoluteFill, loadingAnimatedStyle]}>
<MetadataLoadingScreen
type={metadata?.type === 'movie' ? 'movie' : 'series'}
/>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={[styles.loadingText, {
color: currentTheme.colors.mediumEmphasis
}]}>
Loading content...
</Text>
</View>
</SafeAreaView>
</Animated.View>
);
}
@ -263,119 +301,126 @@ const MetadataScreen = () => {
}
return (
<SafeAreaView
style={[containerAnimatedStyle, styles.container, {
backgroundColor: currentTheme.colors.darkBackground
}]}
edges={['bottom']}
>
<StatusBar
translucent={true}
backgroundColor="transparent"
barStyle="light-content"
animated={true}
/>
<Animated.View style={containerAnimatedStyle}>
{/* Floating Header */}
<FloatingHeader
metadata={metadata}
logoLoadError={logoLoadError}
handleBack={handleBack}
handleToggleLibrary={handleToggleLibrary}
inLibrary={inLibrary}
headerOpacity={animations.headerOpacity}
headerElementsY={animations.headerElementsY}
headerElementsOpacity={animations.headerElementsOpacity}
safeAreaTop={safeAreaTop}
setLogoLoadError={setLogoLoadError}
<Animated.View style={[StyleSheet.absoluteFill, contentTransitionStyle]}>
<SafeAreaView
style={[containerAnimatedStyle, styles.container, {
backgroundColor: currentTheme.colors.darkBackground
}]}
edges={['bottom']}
>
<StatusBar
translucent={true}
backgroundColor="transparent"
barStyle="light-content"
animated={true}
/>
<Animated.ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
onScroll={animations.scrollHandler}
scrollEventThrottle={16}
>
{/* Hero Section */}
<HeroSection
<Animated.View style={containerAnimatedStyle}>
{/* Floating Header */}
<FloatingHeader
metadata={metadata}
bannerImage={bannerImage}
loadingBanner={loadingBanner}
logoLoadError={logoLoadError}
scrollY={animations.scrollY}
dampedScrollY={animations.dampedScrollY}
heroHeight={animations.heroHeight}
heroOpacity={animations.heroOpacity}
heroScale={animations.heroScale}
logoOpacity={animations.logoOpacity}
logoScale={animations.logoScale}
genresOpacity={animations.genresOpacity}
genresTranslateY={animations.genresTranslateY}
buttonsOpacity={animations.buttonsOpacity}
buttonsTranslateY={animations.buttonsTranslateY}
watchProgressOpacity={animations.watchProgressOpacity}
watchProgressScaleY={animations.watchProgressScaleY}
watchProgress={watchProgress}
type={type as 'movie' | 'series'}
getEpisodeDetails={getEpisodeDetails}
handleShowStreams={handleShowStreams}
handleBack={handleBack}
handleToggleLibrary={handleToggleLibrary}
inLibrary={inLibrary}
id={id}
navigation={navigation}
getPlayButtonText={getPlayButtonText}
setBannerImage={setBannerImage}
inLibrary={inLibrary}
headerOpacity={animations.headerOpacity}
headerElementsY={animations.headerElementsY}
headerElementsOpacity={animations.headerElementsOpacity}
safeAreaTop={safeAreaTop}
setLogoLoadError={setLogoLoadError}
/>
/>
{/* Main Content */}
<Animated.View style={contentAnimatedStyle}>
{/* Metadata Details */}
<MetadataDetails
<Animated.ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
onScroll={animations.scrollHandler}
scrollEventThrottle={16}
>
{/* Hero Section */}
<HeroSection
metadata={metadata}
imdbId={imdbId}
bannerImage={bannerImage}
loadingBanner={loadingBanner}
logoLoadError={logoLoadError}
scrollY={animations.scrollY}
dampedScrollY={animations.dampedScrollY}
heroHeight={animations.heroHeight}
heroOpacity={animations.heroOpacity}
heroScale={animations.heroScale}
heroRotate={animations.heroRotate}
logoOpacity={animations.logoOpacity}
logoScale={animations.logoScale}
logoRotate={animations.logoRotate}
genresOpacity={animations.genresOpacity}
genresTranslateY={animations.genresTranslateY}
genresScale={animations.genresScale}
buttonsOpacity={animations.buttonsOpacity}
buttonsTranslateY={animations.buttonsTranslateY}
buttonsScale={animations.buttonsScale}
watchProgressOpacity={animations.watchProgressOpacity}
watchProgressScaleY={animations.watchProgressScaleY}
watchProgressWidth={animations.watchProgressWidth}
watchProgress={watchProgress}
type={type as 'movie' | 'series'}
renderRatings={() => imdbId ? (
<RatingsSection
imdbId={imdbId}
type={type === 'series' ? 'show' : 'movie'}
/>
) : null}
getEpisodeDetails={getEpisodeDetails}
handleShowStreams={handleShowStreams}
handleToggleLibrary={handleToggleLibrary}
inLibrary={inLibrary}
id={id}
navigation={navigation}
getPlayButtonText={getPlayButtonText}
setBannerImage={setBannerImage}
setLogoLoadError={setLogoLoadError}
/>
{/* Cast Section */}
<CastSection
cast={cast}
loadingCast={loadingCast}
onSelectCastMember={handleSelectCastMember}
/>
{/* More Like This Section - Only for movies */}
{type === 'movie' && (
<MoreLikeThisSection
recommendations={recommendations}
loadingRecommendations={loadingRecommendations}
/>
)}
{/* Type-specific content */}
{type === 'series' ? (
<SeriesContent
episodes={episodes}
selectedSeason={selectedSeason}
loadingSeasons={loadingSeasons}
onSeasonChange={handleSeasonChangeWithHaptics}
onSelectEpisode={handleEpisodeSelect}
groupedEpisodes={groupedEpisodes}
{/* Main Content */}
<Animated.View style={contentAnimatedStyle}>
{/* Metadata Details */}
<MetadataDetails
metadata={metadata}
imdbId={imdbId}
type={type as 'movie' | 'series'}
renderRatings={() => imdbId ? (
<RatingsSection
imdbId={imdbId}
type={type === 'series' ? 'show' : 'movie'}
/>
) : null}
/>
) : (
<MovieContent metadata={metadata} />
)}
</Animated.View>
</Animated.ScrollView>
</Animated.View>
</SafeAreaView>
{/* Cast Section */}
<CastSection
cast={cast}
loadingCast={loadingCast}
onSelectCastMember={handleSelectCastMember}
/>
{/* More Like This Section - Only for movies */}
{type === 'movie' && (
<MoreLikeThisSection
recommendations={recommendations}
loadingRecommendations={loadingRecommendations}
/>
)}
{/* Type-specific content */}
{type === 'series' ? (
<SeriesContent
episodes={episodes}
selectedSeason={selectedSeason}
loadingSeasons={loadingSeasons}
onSeasonChange={handleSeasonChangeWithHaptics}
onSelectEpisode={handleEpisodeSelect}
groupedEpisodes={groupedEpisodes}
metadata={metadata}
/>
) : (
<MovieContent metadata={metadata} />
)}
</Animated.View>
</Animated.ScrollView>
</Animated.View>
</SafeAreaView>
</Animated.View>
);
};

File diff suppressed because it is too large Load diff