mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
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:
parent
a37d4f5a8b
commit
4a94e6248d
16 changed files with 3804 additions and 3427 deletions
3
eas.json
3
eas.json
|
|
@ -12,7 +12,8 @@
|
|||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
"autoIncrement": true,
|
||||
"extends": "apk"
|
||||
},
|
||||
"release": {
|
||||
"distribution": "store",
|
||||
|
|
|
|||
305
src/components/loading/MetadataLoadingScreen.tsx
Normal file
305
src/components/loading/MetadataLoadingScreen.tsx
Normal 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;
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
900
src/components/player/VideoPlayer.tsx
Normal file
900
src/components/player/VideoPlayer.tsx
Normal 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;
|
||||
217
src/components/player/controls/PlayerControls.tsx
Normal file
217
src/components/player/controls/PlayerControls.tsx
Normal 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;
|
||||
75
src/components/player/modals/AudioTrackModal.tsx
Normal file
75
src/components/player/modals/AudioTrackModal.tsx
Normal 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;
|
||||
115
src/components/player/modals/ResumeOverlay.tsx
Normal file
115
src/components/player/modals/ResumeOverlay.tsx
Normal 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;
|
||||
281
src/components/player/modals/SubtitleModals.tsx
Normal file
281
src/components/player/modals/SubtitleModals.tsx
Normal 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;
|
||||
29
src/components/player/subtitles/CustomSubtitles.tsx
Normal file
29
src/components/player/subtitles/CustomSubtitles.tsx
Normal 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;
|
||||
755
src/components/player/utils/playerStyles.ts
Normal file
755
src/components/player/utils/playerStyles.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
88
src/components/player/utils/playerTypes.ts
Normal file
88
src/components/player/utils/playerTypes.ts
Normal 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;
|
||||
}
|
||||
219
src/components/player/utils/playerUtils.ts
Normal file
219
src/components/player/utils/playerUtils.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue