mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
imporved behaviour of videoplayer and added a nice loading screen
This commit is contained in:
parent
ab7134bd3a
commit
379bcc7507
8 changed files with 257 additions and 32 deletions
|
|
@ -95,9 +95,6 @@ android {
|
|||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0.0"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
|
||||
}
|
||||
}
|
||||
signingConfigs {
|
||||
debug {
|
||||
|
|
@ -129,16 +126,6 @@ android {
|
|||
androidResources {
|
||||
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`
|
||||
|
|
|
|||
7
ios/Nuvio.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
ios/Nuvio.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
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 { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||
|
|
@ -10,8 +10,11 @@ import { storageService } from '../../services/storageService';
|
|||
import { logger } from '../../utils/logger';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useTraktAutosync } from '../../hooks/useTraktAutosync';
|
||||
import { useTraktAutosyncSettings } from '../../hooks/useTraktAutosyncSettings';
|
||||
import { useMetadata } from '../../hooks/useMetadata';
|
||||
import { useSettings } from '../../hooks/useSettings';
|
||||
|
||||
import {
|
||||
DEFAULT_SUBTITLE_SIZE,
|
||||
|
|
@ -62,7 +65,8 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
type,
|
||||
episodeId,
|
||||
imdbId,
|
||||
availableStreams: passedAvailableStreams
|
||||
availableStreams: passedAvailableStreams,
|
||||
backdrop
|
||||
} = route.params;
|
||||
|
||||
// Initialize Trakt autosync
|
||||
|
|
@ -157,7 +161,25 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
const isMounted = useRef(true);
|
||||
const controlsTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
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 hideControls = () => {
|
||||
|
|
@ -241,7 +263,51 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
}, []);
|
||||
|
||||
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 = () => {
|
||||
|
|
@ -951,6 +1017,25 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
]}
|
||||
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
|
||||
style={styles.loadingCloseButton}
|
||||
onPress={handleClose}
|
||||
|
|
@ -960,8 +1045,28 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
</TouchableOpacity>
|
||||
|
||||
<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" />
|
||||
<Text style={styles.openingText}>Loading video...</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||
|
|
@ -10,16 +10,19 @@ import { storageService } from '../../services/storageService';
|
|||
import { logger } from '../../utils/logger';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import AndroidVideoPlayer from './AndroidVideoPlayer';
|
||||
import { useTraktAutosync } from '../../hooks/useTraktAutosync';
|
||||
import { useTraktAutosyncSettings } from '../../hooks/useTraktAutosyncSettings';
|
||||
import { useMetadata } from '../../hooks/useMetadata';
|
||||
import { useSettings } from '../../hooks/useSettings';
|
||||
|
||||
import {
|
||||
DEFAULT_SUBTITLE_SIZE,
|
||||
AudioTrack,
|
||||
TextTrack,
|
||||
ResizeModeType,
|
||||
WyzieSubtitle,
|
||||
WyzieSubtitle,
|
||||
SubtitleCue,
|
||||
RESUME_PREF_KEY,
|
||||
RESUME_PREF,
|
||||
|
|
@ -57,7 +60,8 @@ const VideoPlayer: React.FC = () => {
|
|||
type,
|
||||
episodeId,
|
||||
imdbId,
|
||||
availableStreams: passedAvailableStreams
|
||||
availableStreams: passedAvailableStreams,
|
||||
backdrop
|
||||
} = route.params;
|
||||
|
||||
// Initialize Trakt autosync
|
||||
|
|
@ -152,6 +156,24 @@ const VideoPlayer: React.FC = () => {
|
|||
const isMounted = useRef(true);
|
||||
const controlsTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
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
|
||||
// file which triggers the `onEnd` callback and causes playback to restart.
|
||||
const END_EPSILON = 0.3;
|
||||
|
|
@ -237,7 +259,51 @@ const VideoPlayer: React.FC = () => {
|
|||
}, []);
|
||||
|
||||
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 = () => {
|
||||
|
|
@ -389,7 +455,6 @@ const VideoPlayer: React.FC = () => {
|
|||
// Calculate position as fraction
|
||||
const position = timeInSeconds / duration;
|
||||
vlcRef.current.seek(position);
|
||||
|
||||
// Clear seek state after Android seek
|
||||
setTimeout(() => {
|
||||
if (isMounted.current) {
|
||||
|
|
@ -400,12 +465,18 @@ const VideoPlayer: React.FC = () => {
|
|||
}
|
||||
}, 500);
|
||||
} else {
|
||||
// iOS fallback - use seek prop
|
||||
const position = timeInSeconds / duration;
|
||||
setSeekPosition(position);
|
||||
|
||||
// iOS (and other platforms) – prefer direct seek on the ref to avoid re-mounts caused by the `seek` prop
|
||||
const position = timeInSeconds / duration; // VLC expects a 0-1 fraction
|
||||
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(() => {
|
||||
if (isMounted.current) {
|
||||
// Reset temporary seek state
|
||||
setSeekPosition(null);
|
||||
isSeeking.current = false;
|
||||
if (DEBUG_MODE) {
|
||||
|
|
@ -967,6 +1038,25 @@ const VideoPlayer: React.FC = () => {
|
|||
]}
|
||||
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
|
||||
style={styles.loadingCloseButton}
|
||||
onPress={handleClose}
|
||||
|
|
@ -976,8 +1066,28 @@ const VideoPlayer: React.FC = () => {
|
|||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.openingContent}>
|
||||
<ActivityIndicator size="large" color="#E50914" />
|
||||
<Text style={styles.openingText}>Loading video...</Text>
|
||||
{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" />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
|
|
@ -1055,7 +1165,6 @@ const VideoPlayer: React.FC = () => {
|
|||
resizeMode={resizeMode as any}
|
||||
audioTrack={selectedAudioTrack ?? undefined}
|
||||
textTrack={useCustomSubtitles ? -1 : (selectedTextTrack ?? undefined)}
|
||||
seek={Platform.OS === 'ios' ? (seekPosition ?? undefined) : undefined}
|
||||
autoAspectRatio
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
|
|
|||
|
|
@ -474,7 +474,6 @@ export const styles = StyleSheet.create({
|
|||
},
|
||||
openingContent: {
|
||||
padding: 20,
|
||||
backgroundColor: 'rgba(0,0,0,0.85)',
|
||||
borderRadius: 10,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export type RootStackParamList = {
|
|||
type: string;
|
||||
stream: Stream;
|
||||
episodeId?: string;
|
||||
backdrop?: string;
|
||||
};
|
||||
Player: {
|
||||
uri: string;
|
||||
|
|
@ -82,6 +83,7 @@ export type RootStackParamList = {
|
|||
episodeId?: string;
|
||||
imdbId?: string;
|
||||
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
|
||||
backdrop?: string;
|
||||
};
|
||||
Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string };
|
||||
Credits: { mediaId: string; mediaType: string };
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { LinearGradient } from 'expo-linear-gradient';
|
|||
import { Image } from 'expo-image';
|
||||
import { RootStackParamList, RootStackNavigationProp } from '../navigation/AppNavigator';
|
||||
import { useMetadata } from '../hooks/useMetadata';
|
||||
import { useMetadataAssets } from '../hooks/useMetadataAssets';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { Stream } from '../types/metadata';
|
||||
import { tmdbService } from '../services/tmdbService';
|
||||
|
|
@ -274,6 +275,11 @@ export const StreamsScreen = () => {
|
|||
imdbId,
|
||||
} = 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
|
||||
const styles = React.useMemo(() => createStyles(colors), [colors]);
|
||||
|
||||
|
|
@ -578,6 +584,7 @@ export const StreamsScreen = () => {
|
|||
episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined,
|
||||
imdbId: imdbId || undefined,
|
||||
availableStreams: streamsToPass,
|
||||
backdrop: bannerImage || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[StreamsScreen] Error locking orientation before navigation:', error);
|
||||
|
|
@ -598,9 +605,10 @@ export const StreamsScreen = () => {
|
|||
episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined,
|
||||
imdbId: imdbId || undefined,
|
||||
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
|
||||
const handleStreamPress = useCallback(async (stream: Stream) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue