imporved behaviour of videoplayer and added a nice loading screen

This commit is contained in:
tapframe 2025-07-07 13:36:32 +05:30
parent ab7134bd3a
commit 379bcc7507
8 changed files with 257 additions and 32 deletions

View file

@ -95,9 +95,6 @@ android {
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 1
versionName "1.0.0" versionName "1.0.0"
ndk {
abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
}
} }
signingConfigs { signingConfigs {
debug { debug {
@ -129,16 +126,6 @@ android {
androidResources { androidResources {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~' ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
} }
// --- ABI SPLITS: generate separate APKs/AABs for each CPU architecture (plus a universal build) ---
splits {
abi {
enable true // turn on ABI splits
reset() // start from a clean slate
include "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
universalApk true // also generate a universal APK that contains all ABIs
}
}
} }
// Apply static values from `gradle.properties` to the `android.packagingOptions` // Apply static values from `gradle.properties` to the `android.packagingOptions`

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View file

@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text } from 'react-native'; import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, Image, StyleSheet } from 'react-native';
import Video, { VideoRef, SelectedTrack, SelectedTrackType, BufferingStrategyType } from 'react-native-video'; import Video, { VideoRef, SelectedTrack, SelectedTrackType, BufferingStrategyType } from 'react-native-video';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator'; import { RootStackParamList } from '../../navigation/AppNavigator';
@ -10,8 +10,11 @@ import { storageService } from '../../services/storageService';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useTraktAutosync } from '../../hooks/useTraktAutosync'; import { useTraktAutosync } from '../../hooks/useTraktAutosync';
import { useTraktAutosyncSettings } from '../../hooks/useTraktAutosyncSettings'; import { useTraktAutosyncSettings } from '../../hooks/useTraktAutosyncSettings';
import { useMetadata } from '../../hooks/useMetadata';
import { useSettings } from '../../hooks/useSettings';
import { import {
DEFAULT_SUBTITLE_SIZE, DEFAULT_SUBTITLE_SIZE,
@ -62,7 +65,8 @@ const AndroidVideoPlayer: React.FC = () => {
type, type,
episodeId, episodeId,
imdbId, imdbId,
availableStreams: passedAvailableStreams availableStreams: passedAvailableStreams,
backdrop
} = route.params; } = route.params;
// Initialize Trakt autosync // Initialize Trakt autosync
@ -157,7 +161,25 @@ const AndroidVideoPlayer: React.FC = () => {
const isMounted = useRef(true); const isMounted = useRef(true);
const controlsTimeout = useRef<NodeJS.Timeout | null>(null); const controlsTimeout = useRef<NodeJS.Timeout | null>(null);
const [isSyncingBeforeClose, setIsSyncingBeforeClose] = useState(false); const [isSyncingBeforeClose, setIsSyncingBeforeClose] = useState(false);
// Offset in seconds to avoid seeking to the exact end, which fires onEnd and resets. // Get metadata to access logo (only if we have a valid id)
const shouldLoadMetadata = Boolean(id && type);
const metadataResult = useMetadata({
id: id || 'placeholder',
type: type || 'movie'
});
const { metadata, loading: metadataLoading } = shouldLoadMetadata ? metadataResult : { metadata: null, loading: false };
const { settings } = useSettings();
// Logo animation values
const logoScaleAnim = useRef(new Animated.Value(0.8)).current;
const logoOpacityAnim = useRef(new Animated.Value(0)).current;
const pulseAnim = useRef(new Animated.Value(1)).current;
// Check if we have a logo to show
const hasLogo = metadata && metadata.logo && !metadataLoading;
// Small offset (in seconds) used to avoid seeking to the *exact* end of the
// file which triggers the `onEnd` callback and causes playback to restart.
const END_EPSILON = 0.3; const END_EPSILON = 0.3;
const hideControls = () => { const hideControls = () => {
@ -241,7 +263,51 @@ const AndroidVideoPlayer: React.FC = () => {
}, []); }, []);
const startOpeningAnimation = () => { const startOpeningAnimation = () => {
// Animation logic here // Logo entrance animation
Animated.parallel([
Animated.timing(logoOpacityAnim, {
toValue: 1,
duration: 600,
useNativeDriver: true,
}),
Animated.spring(logoScaleAnim, {
toValue: 1,
tension: 50,
friction: 8,
useNativeDriver: true,
}),
]).start();
// Continuous pulse animation for the logo
const createPulseAnimation = () => {
return Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.05,
duration: 1000,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}),
]);
};
const loopPulse = () => {
createPulseAnimation().start(() => {
if (!isOpeningAnimationComplete) {
loopPulse();
}
});
};
// Start pulsing after a short delay
setTimeout(() => {
if (!isOpeningAnimationComplete) {
loopPulse();
}
}, 800);
}; };
const completeOpeningAnimation = () => { const completeOpeningAnimation = () => {
@ -951,6 +1017,25 @@ const AndroidVideoPlayer: React.FC = () => {
]} ]}
pointerEvents={isOpeningAnimationComplete ? 'none' : 'auto'} pointerEvents={isOpeningAnimationComplete ? 'none' : 'auto'}
> >
{backdrop && (
<Image
source={{ uri: backdrop }}
style={[StyleSheet.absoluteFill, { width: screenDimensions.width, height: screenDimensions.height }]}
resizeMode="cover"
blurRadius={0}
/>
)}
<LinearGradient
colors={[
'rgba(0,0,0,0.3)',
'rgba(0,0,0,0.6)',
'rgba(0,0,0,0.8)',
'rgba(0,0,0,0.9)'
]}
locations={[0, 0.3, 0.7, 1]}
style={StyleSheet.absoluteFill}
/>
<TouchableOpacity <TouchableOpacity
style={styles.loadingCloseButton} style={styles.loadingCloseButton}
onPress={handleClose} onPress={handleClose}
@ -960,8 +1045,28 @@ const AndroidVideoPlayer: React.FC = () => {
</TouchableOpacity> </TouchableOpacity>
<View style={styles.openingContent}> <View style={styles.openingContent}>
{hasLogo ? (
<Animated.View style={{
transform: [
{ scale: Animated.multiply(logoScaleAnim, pulseAnim) }
],
opacity: logoOpacityAnim,
alignItems: 'center',
}}>
<Image
source={{ uri: metadata.logo }}
style={{
width: 300,
height: 180,
resizeMode: 'contain',
}}
/>
</Animated.View>
) : (
<>
<ActivityIndicator size="large" color="#E50914" /> <ActivityIndicator size="large" color="#E50914" />
<Text style={styles.openingText}>Loading video...</Text> </>
)}
</View> </View>
</Animated.View> </Animated.View>

View file

@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text } from 'react-native'; import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, Image, StyleSheet } from 'react-native';
import { VLCPlayer } from 'react-native-vlc-media-player'; import { VLCPlayer } from 'react-native-vlc-media-player';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator'; import { RootStackParamList } from '../../navigation/AppNavigator';
@ -10,16 +10,19 @@ import { storageService } from '../../services/storageService';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import AndroidVideoPlayer from './AndroidVideoPlayer'; import AndroidVideoPlayer from './AndroidVideoPlayer';
import { useTraktAutosync } from '../../hooks/useTraktAutosync'; import { useTraktAutosync } from '../../hooks/useTraktAutosync';
import { useTraktAutosyncSettings } from '../../hooks/useTraktAutosyncSettings'; import { useTraktAutosyncSettings } from '../../hooks/useTraktAutosyncSettings';
import { useMetadata } from '../../hooks/useMetadata';
import { useSettings } from '../../hooks/useSettings';
import { import {
DEFAULT_SUBTITLE_SIZE, DEFAULT_SUBTITLE_SIZE,
AudioTrack, AudioTrack,
TextTrack, TextTrack,
ResizeModeType, ResizeModeType,
WyzieSubtitle, WyzieSubtitle,
SubtitleCue, SubtitleCue,
RESUME_PREF_KEY, RESUME_PREF_KEY,
RESUME_PREF, RESUME_PREF,
@ -57,7 +60,8 @@ const VideoPlayer: React.FC = () => {
type, type,
episodeId, episodeId,
imdbId, imdbId,
availableStreams: passedAvailableStreams availableStreams: passedAvailableStreams,
backdrop
} = route.params; } = route.params;
// Initialize Trakt autosync // Initialize Trakt autosync
@ -152,6 +156,24 @@ const VideoPlayer: React.FC = () => {
const isMounted = useRef(true); const isMounted = useRef(true);
const controlsTimeout = useRef<NodeJS.Timeout | null>(null); const controlsTimeout = useRef<NodeJS.Timeout | null>(null);
const [isSyncingBeforeClose, setIsSyncingBeforeClose] = useState(false); const [isSyncingBeforeClose, setIsSyncingBeforeClose] = useState(false);
// Get metadata to access logo (only if we have a valid id)
const shouldLoadMetadata = Boolean(id && type);
const metadataResult = useMetadata({
id: id || 'placeholder',
type: type || 'movie'
});
const { metadata, loading: metadataLoading } = shouldLoadMetadata ? metadataResult : { metadata: null, loading: false };
const { settings } = useSettings();
// Logo animation values
const logoScaleAnim = useRef(new Animated.Value(0.8)).current;
const logoOpacityAnim = useRef(new Animated.Value(0)).current;
const pulseAnim = useRef(new Animated.Value(1)).current;
// Check if we have a logo to show
const hasLogo = metadata && metadata.logo && !metadataLoading;
// Small offset (in seconds) used to avoid seeking to the *exact* end of the // Small offset (in seconds) used to avoid seeking to the *exact* end of the
// file which triggers the `onEnd` callback and causes playback to restart. // file which triggers the `onEnd` callback and causes playback to restart.
const END_EPSILON = 0.3; const END_EPSILON = 0.3;
@ -237,7 +259,51 @@ const VideoPlayer: React.FC = () => {
}, []); }, []);
const startOpeningAnimation = () => { const startOpeningAnimation = () => {
// Animation logic here // Logo entrance animation
Animated.parallel([
Animated.timing(logoOpacityAnim, {
toValue: 1,
duration: 600,
useNativeDriver: true,
}),
Animated.spring(logoScaleAnim, {
toValue: 1,
tension: 50,
friction: 8,
useNativeDriver: true,
}),
]).start();
// Continuous pulse animation for the logo
const createPulseAnimation = () => {
return Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.05,
duration: 1000,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}),
]);
};
const loopPulse = () => {
createPulseAnimation().start(() => {
if (!isOpeningAnimationComplete) {
loopPulse();
}
});
};
// Start pulsing after a short delay
setTimeout(() => {
if (!isOpeningAnimationComplete) {
loopPulse();
}
}, 800);
}; };
const completeOpeningAnimation = () => { const completeOpeningAnimation = () => {
@ -389,7 +455,6 @@ const VideoPlayer: React.FC = () => {
// Calculate position as fraction // Calculate position as fraction
const position = timeInSeconds / duration; const position = timeInSeconds / duration;
vlcRef.current.seek(position); vlcRef.current.seek(position);
// Clear seek state after Android seek // Clear seek state after Android seek
setTimeout(() => { setTimeout(() => {
if (isMounted.current) { if (isMounted.current) {
@ -400,12 +465,18 @@ const VideoPlayer: React.FC = () => {
} }
}, 500); }, 500);
} else { } else {
// iOS fallback - use seek prop // iOS (and other platforms) prefer direct seek on the ref to avoid re-mounts caused by the `seek` prop
const position = timeInSeconds / duration; const position = timeInSeconds / duration; // VLC expects a 0-1 fraction
setSeekPosition(position); if (vlcRef.current && typeof vlcRef.current.seek === 'function') {
vlcRef.current.seek(position);
} else {
// Fallback to legacy behaviour only if direct seek is unavailable
setSeekPosition(position);
}
setTimeout(() => { setTimeout(() => {
if (isMounted.current) { if (isMounted.current) {
// Reset temporary seek state
setSeekPosition(null); setSeekPosition(null);
isSeeking.current = false; isSeeking.current = false;
if (DEBUG_MODE) { if (DEBUG_MODE) {
@ -967,6 +1038,25 @@ const VideoPlayer: React.FC = () => {
]} ]}
pointerEvents={isOpeningAnimationComplete ? 'none' : 'auto'} pointerEvents={isOpeningAnimationComplete ? 'none' : 'auto'}
> >
{backdrop && (
<Image
source={{ uri: backdrop }}
style={[StyleSheet.absoluteFill, { width: screenDimensions.width, height: screenDimensions.height }]}
resizeMode="cover"
blurRadius={0}
/>
)}
<LinearGradient
colors={[
'rgba(0,0,0,0.3)',
'rgba(0,0,0,0.6)',
'rgba(0,0,0,0.8)',
'rgba(0,0,0,0.9)'
]}
locations={[0, 0.3, 0.7, 1]}
style={StyleSheet.absoluteFill}
/>
<TouchableOpacity <TouchableOpacity
style={styles.loadingCloseButton} style={styles.loadingCloseButton}
onPress={handleClose} onPress={handleClose}
@ -976,8 +1066,28 @@ const VideoPlayer: React.FC = () => {
</TouchableOpacity> </TouchableOpacity>
<View style={styles.openingContent}> <View style={styles.openingContent}>
<ActivityIndicator size="large" color="#E50914" /> {hasLogo ? (
<Text style={styles.openingText}>Loading video...</Text> <Animated.View style={{
transform: [
{ scale: Animated.multiply(logoScaleAnim, pulseAnim) }
],
opacity: logoOpacityAnim,
alignItems: 'center',
}}>
<Image
source={{ uri: metadata.logo }}
style={{
width: 300,
height: 180,
resizeMode: 'contain',
}}
/>
</Animated.View>
) : (
<>
<ActivityIndicator size="large" color="#E50914" />
</>
)}
</View> </View>
</Animated.View> </Animated.View>
@ -1055,7 +1165,6 @@ const VideoPlayer: React.FC = () => {
resizeMode={resizeMode as any} resizeMode={resizeMode as any}
audioTrack={selectedAudioTrack ?? undefined} audioTrack={selectedAudioTrack ?? undefined}
textTrack={useCustomSubtitles ? -1 : (selectedTextTrack ?? undefined)} textTrack={useCustomSubtitles ? -1 : (selectedTextTrack ?? undefined)}
seek={Platform.OS === 'ios' ? (seekPosition ?? undefined) : undefined}
autoAspectRatio autoAspectRatio
/> />
</TouchableOpacity> </TouchableOpacity>

View file

@ -474,7 +474,6 @@ export const styles = StyleSheet.create({
}, },
openingContent: { openingContent: {
padding: 20, padding: 20,
backgroundColor: 'rgba(0,0,0,0.85)',
borderRadius: 10, borderRadius: 10,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',

View file

@ -66,6 +66,7 @@ export type RootStackParamList = {
type: string; type: string;
stream: Stream; stream: Stream;
episodeId?: string; episodeId?: string;
backdrop?: string;
}; };
Player: { Player: {
uri: string; uri: string;
@ -82,6 +83,7 @@ export type RootStackParamList = {
episodeId?: string; episodeId?: string;
imdbId?: string; imdbId?: string;
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } }; availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
backdrop?: string;
}; };
Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string }; Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string };
Credits: { mediaId: string; mediaType: string }; Credits: { mediaId: string; mediaType: string };

View file

@ -24,6 +24,7 @@ import { LinearGradient } from 'expo-linear-gradient';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { RootStackParamList, RootStackNavigationProp } from '../navigation/AppNavigator'; import { RootStackParamList, RootStackNavigationProp } from '../navigation/AppNavigator';
import { useMetadata } from '../hooks/useMetadata'; import { useMetadata } from '../hooks/useMetadata';
import { useMetadataAssets } from '../hooks/useMetadataAssets';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import { Stream } from '../types/metadata'; import { Stream } from '../types/metadata';
import { tmdbService } from '../services/tmdbService'; import { tmdbService } from '../services/tmdbService';
@ -274,6 +275,11 @@ export const StreamsScreen = () => {
imdbId, imdbId,
} = useMetadata({ id, type }); } = useMetadata({ id, type });
// Get backdrop from metadata assets
const setMetadataStub = useCallback(() => {}, []);
const memoizedSettings = useMemo(() => settings, [settings.logoSourcePreference, settings.tmdbLanguagePreference]);
const { bannerImage } = useMetadataAssets(metadata, id, type, imdbId, memoizedSettings, setMetadataStub);
// Create styles using current theme colors // Create styles using current theme colors
const styles = React.useMemo(() => createStyles(colors), [colors]); const styles = React.useMemo(() => createStyles(colors), [colors]);
@ -578,6 +584,7 @@ export const StreamsScreen = () => {
episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined, episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined,
imdbId: imdbId || undefined, imdbId: imdbId || undefined,
availableStreams: streamsToPass, availableStreams: streamsToPass,
backdrop: bannerImage || undefined,
}); });
} catch (error) { } catch (error) {
logger.error('[StreamsScreen] Error locking orientation before navigation:', error); logger.error('[StreamsScreen] Error locking orientation before navigation:', error);
@ -598,9 +605,10 @@ export const StreamsScreen = () => {
episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined, episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined,
imdbId: imdbId || undefined, imdbId: imdbId || undefined,
availableStreams: streamsToPass, availableStreams: streamsToPass,
backdrop: bannerImage || undefined,
}); });
} }
}, [metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId, episodeStreams, groupedStreams]); }, [metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId, episodeStreams, groupedStreams, bannerImage]);
// Update handleStreamPress // Update handleStreamPress
const handleStreamPress = useCallback(async (stream: Stream) => { const handleStreamPress = useCallback(async (stream: Stream) => {