mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-21 00:32:04 +00:00
fixed audio selection issue
This commit is contained in:
parent
fa7352f4fa
commit
fa03d4455f
6 changed files with 344 additions and 143 deletions
1
KSPlayer
Submodule
1
KSPlayer
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 8fe5feb73ca3ee5092d2ed1dd8fcb692c10e3c11
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit 5c020cca433f0400e23eb553f3e4de09f65b66d3
|
Subproject commit 0a273ba5eb2632979a6678e005314c5b3ffb70eb
|
||||||
|
|
@ -757,12 +757,35 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
|
|
||||||
// Handle audio tracks
|
// Handle audio tracks
|
||||||
if (data.audioTracks && data.audioTracks.length > 0) {
|
if (data.audioTracks && data.audioTracks.length > 0) {
|
||||||
const formattedAudioTracks = data.audioTracks.map((track: any, index: number) => ({
|
const formattedAudioTracks = data.audioTracks.map((track: any, index: number) => {
|
||||||
id: track.index || index,
|
const trackIndex = track.index !== undefined ? track.index : index;
|
||||||
name: track.title || track.language || `Audio ${index + 1}`,
|
const trackName = track.title || track.language || `Audio ${index + 1}`;
|
||||||
language: track.language,
|
const trackLanguage = track.language || 'Unknown';
|
||||||
}));
|
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
logger.log(`[AndroidVideoPlayer] Audio track ${index}: index=${trackIndex}, name="${trackName}", language="${trackLanguage}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: trackIndex, // Use the actual track index from react-native-video
|
||||||
|
name: trackName,
|
||||||
|
language: trackLanguage,
|
||||||
|
};
|
||||||
|
});
|
||||||
setRnVideoAudioTracks(formattedAudioTracks);
|
setRnVideoAudioTracks(formattedAudioTracks);
|
||||||
|
|
||||||
|
// Auto-select the first audio track if none is selected
|
||||||
|
if (selectedAudioTrack === null && formattedAudioTracks.length > 0) {
|
||||||
|
const firstTrack = formattedAudioTracks[0];
|
||||||
|
setSelectedAudioTrack(firstTrack.id);
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
logger.log(`[AndroidVideoPlayer] Auto-selected first audio track: ${firstTrack.name} (ID: ${firstTrack.id})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
logger.log(`[AndroidVideoPlayer] Formatted audio tracks:`, formattedAudioTracks);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle text tracks
|
// Handle text tracks
|
||||||
|
|
@ -1205,7 +1228,42 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectAudioTrack = (trackId: number) => {
|
const selectAudioTrack = (trackId: number) => {
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
logger.log(`[AndroidVideoPlayer] Selecting audio track: ${trackId}`);
|
||||||
|
logger.log(`[AndroidVideoPlayer] Available tracks:`, rnVideoAudioTracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that the track exists
|
||||||
|
const trackExists = rnVideoAudioTracks.some(track => track.id === trackId);
|
||||||
|
if (!trackExists) {
|
||||||
|
logger.error(`[AndroidVideoPlayer] Audio track ${trackId} not found in available tracks`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If changing tracks, briefly pause to allow smooth transition
|
||||||
|
const wasPlaying = !paused;
|
||||||
|
if (wasPlaying) {
|
||||||
|
setPaused(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the new audio track
|
||||||
setSelectedAudioTrack(trackId);
|
setSelectedAudioTrack(trackId);
|
||||||
|
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
logger.log(`[AndroidVideoPlayer] Audio track changed to: ${trackId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume playback after a brief delay if it was playing
|
||||||
|
if (wasPlaying) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isMounted.current) {
|
||||||
|
setPaused(false);
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
logger.log(`[AndroidVideoPlayer] Resumed playback after audio track change`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectTextTrack = (trackId: number) => {
|
const selectTextTrack = (trackId: number) => {
|
||||||
|
|
@ -1737,6 +1795,20 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
loadSubtitleSize();
|
loadSubtitleSize();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Handle audio track changes with proper logging
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedAudioTrack !== null && rnVideoAudioTracks.length > 0) {
|
||||||
|
const selectedTrack = rnVideoAudioTracks.find(track => track.id === selectedAudioTrack);
|
||||||
|
if (selectedTrack) {
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
logger.log(`[AndroidVideoPlayer] Audio track selected: ${selectedTrack.name} (${selectedTrack.language}) - ID: ${selectedAudioTrack}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`[AndroidVideoPlayer] Selected audio track ${selectedAudioTrack} not found in available tracks`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedAudioTrack, rnVideoAudioTracks]);
|
||||||
|
|
||||||
// Load global subtitle settings
|
// Load global subtitle settings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
|
||||||
|
|
@ -751,7 +751,35 @@ const VideoPlayer: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.audioTracks && data.audioTracks.length > 0) {
|
if (data.audioTracks && data.audioTracks.length > 0) {
|
||||||
setVlcAudioTracks(data.audioTracks);
|
const formattedAudioTracks = data.audioTracks.map((track: any, index: number) => {
|
||||||
|
const trackIndex = track.index !== undefined ? track.index : index;
|
||||||
|
const trackName = track.title || track.language || `Audio ${index + 1}`;
|
||||||
|
const trackLanguage = track.language || 'Unknown';
|
||||||
|
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
logger.log(`[VideoPlayer] Audio track ${index}: index=${trackIndex}, name="${trackName}", language="${trackLanguage}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: trackIndex, // Use the actual track index from VLC
|
||||||
|
name: trackName,
|
||||||
|
language: trackLanguage,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setVlcAudioTracks(formattedAudioTracks);
|
||||||
|
|
||||||
|
// Auto-select the first audio track if none is selected
|
||||||
|
if (selectedAudioTrack === null && formattedAudioTracks.length > 0) {
|
||||||
|
const firstTrack = formattedAudioTracks[0];
|
||||||
|
setSelectedAudioTrack(firstTrack.id);
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
logger.log(`[VideoPlayer] Auto-selected first audio track: ${firstTrack.name} (ID: ${firstTrack.id})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
logger.log(`[VideoPlayer] Formatted audio tracks:`, formattedAudioTracks);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (data.textTracks && data.textTracks.length > 0) {
|
if (data.textTracks && data.textTracks.length > 0) {
|
||||||
setVlcTextTracks(data.textTracks);
|
setVlcTextTracks(data.textTracks);
|
||||||
|
|
@ -1024,7 +1052,42 @@ const VideoPlayer: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectAudioTrack = (trackId: number) => {
|
const selectAudioTrack = (trackId: number) => {
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
logger.log(`[VideoPlayer] Selecting audio track: ${trackId}`);
|
||||||
|
logger.log(`[VideoPlayer] Available tracks:`, vlcAudioTracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that the track exists
|
||||||
|
const trackExists = vlcAudioTracks.some(track => track.id === trackId);
|
||||||
|
if (!trackExists) {
|
||||||
|
logger.error(`[VideoPlayer] Audio track ${trackId} not found in available tracks`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If changing tracks, briefly pause to allow smooth transition
|
||||||
|
const wasPlaying = !paused;
|
||||||
|
if (wasPlaying) {
|
||||||
|
setPaused(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the new audio track
|
||||||
setSelectedAudioTrack(trackId);
|
setSelectedAudioTrack(trackId);
|
||||||
|
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
logger.log(`[VideoPlayer] Audio track changed to: ${trackId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume playback after a brief delay if it was playing
|
||||||
|
if (wasPlaying) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isMounted.current) {
|
||||||
|
setPaused(false);
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
logger.log(`[VideoPlayer] Resumed playback after audio track change`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectTextTrack = (trackId: number) => {
|
const selectTextTrack = (trackId: number) => {
|
||||||
|
|
@ -1578,6 +1641,20 @@ const VideoPlayer: React.FC = () => {
|
||||||
loadSubtitleSize();
|
loadSubtitleSize();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Handle audio track changes with proper logging
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedAudioTrack !== null && vlcAudioTracks.length > 0) {
|
||||||
|
const selectedTrack = vlcAudioTracks.find(track => track.id === selectedAudioTrack);
|
||||||
|
if (selectedTrack) {
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
logger.log(`[VideoPlayer] Audio track selected: ${selectedTrack.name} (${selectedTrack.language}) - ID: ${selectedAudioTrack}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`[VideoPlayer] Selected audio track ${selectedAudioTrack} not found in available tracks`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedAudioTrack, vlcAudioTracks]);
|
||||||
|
|
||||||
const increaseSubtitleSize = () => {
|
const increaseSubtitleSize = () => {
|
||||||
const newSize = Math.min(subtitleSize + 2, 32);
|
const newSize = Math.min(subtitleSize + 2, 32);
|
||||||
saveSubtitleSize(newSize);
|
saveSubtitleSize(newSize);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ import Animated, {
|
||||||
SlideInRight,
|
SlideInRight,
|
||||||
SlideOutRight,
|
SlideOutRight,
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
import { getTrackDisplayName } from '../utils/playerUtils';
|
import { getTrackDisplayName, DEBUG_MODE } from '../utils/playerUtils';
|
||||||
|
import { logger } from '../../../utils/logger';
|
||||||
|
|
||||||
interface AudioTrackModalProps {
|
interface AudioTrackModalProps {
|
||||||
showAudioModal: boolean;
|
showAudioModal: boolean;
|
||||||
|
|
@ -31,6 +32,20 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
|
||||||
setShowAudioModal(false);
|
setShowAudioModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Debug logging when modal opens
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (showAudioModal && DEBUG_MODE) {
|
||||||
|
logger.log(`[AudioTrackModal] Modal opened with selectedAudioTrack: ${selectedAudioTrack}`);
|
||||||
|
logger.log(`[AudioTrackModal] Available tracks:`, vlcAudioTracks);
|
||||||
|
const selectedTrack = vlcAudioTracks.find(track => track.id === selectedAudioTrack);
|
||||||
|
if (selectedTrack) {
|
||||||
|
logger.log(`[AudioTrackModal] Selected track found: ${selectedTrack.name} (${selectedTrack.language})`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`[AudioTrackModal] Selected track ${selectedAudioTrack} not found in available tracks`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showAudioModal, selectedAudioTrack, vlcAudioTracks]);
|
||||||
|
|
||||||
if (!showAudioModal) return null;
|
if (!showAudioModal) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -131,7 +146,9 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
|
||||||
|
|
||||||
<View style={{ gap: 8 }}>
|
<View style={{ gap: 8 }}>
|
||||||
{vlcAudioTracks.map((track) => {
|
{vlcAudioTracks.map((track) => {
|
||||||
const isSelected = selectedAudioTrack === track.id;
|
// If no track is selected, show the first track as selected
|
||||||
|
const isSelected = selectedAudioTrack === track.id ||
|
||||||
|
(selectedAudioTrack === null && track.id === vlcAudioTracks[0]?.id);
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={track.id}
|
key={track.id}
|
||||||
|
|
@ -143,7 +160,14 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
|
||||||
borderColor: isSelected ? 'rgba(34, 197, 94, 0.3)' : 'rgba(255, 255, 255, 0.1)',
|
borderColor: isSelected ? 'rgba(34, 197, 94, 0.3)' : 'rgba(255, 255, 255, 0.1)',
|
||||||
}}
|
}}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
logger.log(`[AudioTrackModal] Selecting track: ${track.id} (${track.name})`);
|
||||||
|
}
|
||||||
selectAudioTrack(track.id);
|
selectAudioTrack(track.id);
|
||||||
|
// Close modal after selection
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowAudioModal(false);
|
||||||
|
}, 200);
|
||||||
}}
|
}}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,13 @@ import {
|
||||||
Clipboard,
|
Clipboard,
|
||||||
Image as RNImage,
|
Image as RNImage,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import Animated, {
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
withTiming,
|
||||||
|
withDelay,
|
||||||
|
runOnJS
|
||||||
|
} from 'react-native-reanimated';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
import * as ScreenOrientation from 'expo-screen-orientation';
|
import * as ScreenOrientation from 'expo-screen-orientation';
|
||||||
|
|
@ -38,22 +45,6 @@ import { localScraperService } from '../services/localScraperService';
|
||||||
import { VideoPlayerService } from '../services/videoPlayerService';
|
import { VideoPlayerService } from '../services/videoPlayerService';
|
||||||
import { useSettings } from '../hooks/useSettings';
|
import { useSettings } from '../hooks/useSettings';
|
||||||
import QualityBadge from '../components/metadata/QualityBadge';
|
import QualityBadge from '../components/metadata/QualityBadge';
|
||||||
import Animated, {
|
|
||||||
FadeIn,
|
|
||||||
FadeOut,
|
|
||||||
FadeInDown,
|
|
||||||
SlideInDown,
|
|
||||||
withSpring,
|
|
||||||
withTiming,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
interpolate,
|
|
||||||
Extrapolate,
|
|
||||||
runOnJS,
|
|
||||||
cancelAnimation,
|
|
||||||
SharedValue,
|
|
||||||
Layout
|
|
||||||
} from 'react-native-reanimated';
|
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906';
|
const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906';
|
||||||
|
|
@ -87,6 +78,102 @@ const detectMkvViaHead = async (url: string, headers?: Record<string, string>) =
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Animated Components
|
||||||
|
const AnimatedImage = memo(({
|
||||||
|
source,
|
||||||
|
style,
|
||||||
|
contentFit,
|
||||||
|
onLoad
|
||||||
|
}: {
|
||||||
|
source: { uri: string } | undefined;
|
||||||
|
style: any;
|
||||||
|
contentFit: any;
|
||||||
|
onLoad?: () => void;
|
||||||
|
}) => {
|
||||||
|
const opacity = useSharedValue(0);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: opacity.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (source?.uri) {
|
||||||
|
opacity.value = withTiming(1, { duration: 300 });
|
||||||
|
}
|
||||||
|
}, [source?.uri]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[style, animatedStyle]}>
|
||||||
|
<Image
|
||||||
|
source={source}
|
||||||
|
style={StyleSheet.absoluteFillObject}
|
||||||
|
contentFit={contentFit}
|
||||||
|
onLoad={onLoad}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const AnimatedText = memo(({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
delay = 0,
|
||||||
|
numberOfLines
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
style: any;
|
||||||
|
delay?: number;
|
||||||
|
numberOfLines?: number;
|
||||||
|
}) => {
|
||||||
|
const opacity = useSharedValue(0);
|
||||||
|
const translateY = useSharedValue(20);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: opacity.value,
|
||||||
|
transform: [{ translateY: translateY.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
opacity.value = withDelay(delay, withTiming(1, { duration: 250 }));
|
||||||
|
translateY.value = withDelay(delay, withTiming(0, { duration: 250 }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.Text style={[style, animatedStyle]} numberOfLines={numberOfLines}>
|
||||||
|
{children}
|
||||||
|
</Animated.Text>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const AnimatedView = memo(({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
delay = 0
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
style?: any;
|
||||||
|
delay?: number;
|
||||||
|
}) => {
|
||||||
|
const opacity = useSharedValue(0);
|
||||||
|
const translateY = useSharedValue(20);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: opacity.value,
|
||||||
|
transform: [{ translateY: translateY.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
opacity.value = withDelay(delay, withTiming(1, { duration: 250 }));
|
||||||
|
translateY.value = withDelay(delay, withTiming(0, { duration: 250 }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[style, animatedStyle]}>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Extracted Components
|
// Extracted Components
|
||||||
const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, theme, showLogos, scraperLogo }: {
|
const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, theme, showLogos, scraperLogo }: {
|
||||||
stream: Stream;
|
stream: Stream;
|
||||||
|
|
@ -269,22 +356,20 @@ const ProviderFilter = memo(({
|
||||||
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
|
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
|
||||||
|
|
||||||
const renderItem = useCallback(({ item, index }: { item: { id: string; name: string }; index: number }) => (
|
const renderItem = useCallback(({ item, index }: { item: { id: string; name: string }; index: number }) => (
|
||||||
<Animated.View entering={FadeIn.duration(300).delay(index * 75)}>
|
<TouchableOpacity
|
||||||
<TouchableOpacity
|
style={[
|
||||||
style={[
|
styles.filterChip,
|
||||||
styles.filterChip,
|
selectedProvider === item.id && styles.filterChipSelected
|
||||||
selectedProvider === item.id && styles.filterChipSelected
|
]}
|
||||||
]}
|
onPress={() => onSelect(item.id)}
|
||||||
onPress={() => onSelect(item.id)}
|
>
|
||||||
>
|
<Text style={[
|
||||||
<Text style={[
|
styles.filterChipText,
|
||||||
styles.filterChipText,
|
selectedProvider === item.id && styles.filterChipTextSelected
|
||||||
selectedProvider === item.id && styles.filterChipTextSelected
|
]}>
|
||||||
]}>
|
{item.name}
|
||||||
{item.name}
|
</Text>
|
||||||
</Text>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
|
||||||
</Animated.View>
|
|
||||||
), [selectedProvider, onSelect, styles]);
|
), [selectedProvider, onSelect, styles]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -383,10 +468,6 @@ export const StreamsScreen = () => {
|
||||||
const [selectedProvider, setSelectedProvider] = React.useState('all');
|
const [selectedProvider, setSelectedProvider] = React.useState('all');
|
||||||
const [availableProviders, setAvailableProviders] = React.useState<Set<string>>(new Set());
|
const [availableProviders, setAvailableProviders] = React.useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Optimize animation values with cleanup
|
|
||||||
const headerOpacity = useSharedValue(0);
|
|
||||||
const heroScale = useSharedValue(0.95);
|
|
||||||
const filterOpacity = useSharedValue(0);
|
|
||||||
|
|
||||||
// Add state for provider loading status
|
// Add state for provider loading status
|
||||||
const [loadingProviders, setLoadingProviders] = useState<{[key: string]: boolean}>({});
|
const [loadingProviders, setLoadingProviders] = useState<{[key: string]: boolean}>({});
|
||||||
|
|
@ -571,34 +652,9 @@ export const StreamsScreen = () => {
|
||||||
checkProviders();
|
checkProviders();
|
||||||
}, [type, id, episodeId, settings.autoplayBestStream, fromPlayer]);
|
}, [type, id, episodeId, settings.autoplayBestStream, fromPlayer]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
// Trigger entrance animations
|
|
||||||
headerOpacity.value = withTiming(1, { duration: 400 });
|
|
||||||
heroScale.value = withSpring(1, {
|
|
||||||
damping: 15,
|
|
||||||
stiffness: 100,
|
|
||||||
mass: 0.9,
|
|
||||||
restDisplacementThreshold: 0.01
|
|
||||||
});
|
|
||||||
filterOpacity.value = withTiming(1, { duration: 500 });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
// Cleanup animations on unmount
|
|
||||||
cancelAnimation(headerOpacity);
|
|
||||||
cancelAnimation(heroScale);
|
|
||||||
cancelAnimation(filterOpacity);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Memoize handlers
|
// Memoize handlers
|
||||||
const handleBack = useCallback(() => {
|
const handleBack = useCallback(() => {
|
||||||
const cleanup = () => {
|
|
||||||
headerOpacity.value = withTiming(0, { duration: 100 });
|
|
||||||
heroScale.value = withTiming(0.95, { duration: 100 });
|
|
||||||
filterOpacity.value = withTiming(0, { duration: 100 });
|
|
||||||
};
|
|
||||||
cleanup();
|
|
||||||
|
|
||||||
if (type === 'series') {
|
if (type === 'series') {
|
||||||
// Reset stack to ensure there is always a screen to go back to from Metadata
|
// Reset stack to ensure there is always a screen to go back to from Metadata
|
||||||
(navigation as any).reset({
|
(navigation as any).reset({
|
||||||
|
|
@ -616,7 +672,7 @@ export const StreamsScreen = () => {
|
||||||
} else {
|
} else {
|
||||||
(navigation as any).navigate('MainTabs');
|
(navigation as any).navigate('MainTabs');
|
||||||
}
|
}
|
||||||
}, [navigation, headerOpacity, heroScale, filterOpacity, type, id]);
|
}, [navigation, type, id]);
|
||||||
|
|
||||||
const handleProviderChange = useCallback((provider: string) => {
|
const handleProviderChange = useCallback((provider: string) => {
|
||||||
setSelectedProvider(provider);
|
setSelectedProvider(provider);
|
||||||
|
|
@ -1430,24 +1486,6 @@ export const StreamsScreen = () => {
|
||||||
const showInitialLoading = streamsEmpty && (streamsLoadStart === null || loadElapsed < 10000);
|
const showInitialLoading = streamsEmpty && (streamsLoadStart === null || loadElapsed < 10000);
|
||||||
const showStillFetching = streamsEmpty && loadElapsed >= 10000;
|
const showStillFetching = streamsEmpty && loadElapsed >= 10000;
|
||||||
|
|
||||||
const heroStyle = useAnimatedStyle(() => ({
|
|
||||||
transform: [{ scale: heroScale.value }],
|
|
||||||
opacity: headerOpacity.value
|
|
||||||
}));
|
|
||||||
|
|
||||||
const filterStyle = useAnimatedStyle(() => ({
|
|
||||||
opacity: filterOpacity.value,
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
translateY: interpolate(
|
|
||||||
filterOpacity.value,
|
|
||||||
[0, 1],
|
|
||||||
[20, 0],
|
|
||||||
Extrapolate.CLAMP
|
|
||||||
)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}));
|
|
||||||
|
|
||||||
const renderItem = useCallback(({ item, index, section }: { item: Stream; index: number; section: any }) => {
|
const renderItem = useCallback(({ item, index, section }: { item: Stream; index: number; section: any }) => {
|
||||||
const stream = item;
|
const stream = item;
|
||||||
|
|
@ -1509,7 +1547,7 @@ export const StreamsScreen = () => {
|
||||||
|
|
||||||
|
|
||||||
{Platform.OS !== 'ios' && (
|
{Platform.OS !== 'ios' && (
|
||||||
<Animated.View
|
<View
|
||||||
style={[styles.backButtonContainer]}
|
style={[styles.backButtonContainer]}
|
||||||
>
|
>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|
@ -1525,38 +1563,37 @@ export const StreamsScreen = () => {
|
||||||
{type === 'series' ? 'Back to Episodes' : 'Back to Info'}
|
{type === 'series' ? 'Back to Episodes' : 'Back to Info'}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Animated.View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{type === 'movie' && metadata && (
|
{type === 'movie' && metadata && (
|
||||||
<Animated.View style={[styles.movieTitleContainer, heroStyle]}>
|
<View style={[styles.movieTitleContainer]}>
|
||||||
<View style={styles.movieTitleContent}>
|
<View style={styles.movieTitleContent}>
|
||||||
{metadata.logo ? (
|
{metadata.logo ? (
|
||||||
<Image
|
<AnimatedImage
|
||||||
source={{ uri: metadata.logo }}
|
source={{ uri: metadata.logo }}
|
||||||
style={styles.movieLogo}
|
style={styles.movieLogo}
|
||||||
contentFit="contain"
|
contentFit="contain"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.movieTitle} numberOfLines={2}>
|
<AnimatedText style={styles.movieTitle} numberOfLines={2}>
|
||||||
{metadata.name}
|
{metadata.name}
|
||||||
</Text>
|
</AnimatedText>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{type === 'series' && (
|
{type === 'series' && (
|
||||||
<Animated.View style={[styles.streamsHeroContainer, heroStyle]}>
|
<View style={[styles.streamsHeroContainer]}>
|
||||||
<Animated.View style={StyleSheet.absoluteFill}>
|
<View style={StyleSheet.absoluteFill}>
|
||||||
<Animated.View
|
<View
|
||||||
style={StyleSheet.absoluteFill}
|
style={StyleSheet.absoluteFill}
|
||||||
>
|
>
|
||||||
<Image
|
<AnimatedImage
|
||||||
source={episodeImage ? { uri: episodeImage } : undefined}
|
source={episodeImage ? { uri: episodeImage } : undefined}
|
||||||
style={styles.streamsHeroBackground}
|
style={styles.streamsHeroBackground}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
transition={500}
|
|
||||||
/>
|
/>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['rgba(0,0,0,0)', 'rgba(0,0,0,0.3)', 'rgba(0,0,0,0.5)', 'rgba(0,0,0,0.7)', colors.darkBackground]}
|
colors={['rgba(0,0,0,0)', 'rgba(0,0,0,0.3)', 'rgba(0,0,0,0.5)', 'rgba(0,0,0,0.7)', colors.darkBackground]}
|
||||||
|
|
@ -1565,58 +1602,60 @@ export const StreamsScreen = () => {
|
||||||
>
|
>
|
||||||
<View style={styles.streamsHeroContent}>
|
<View style={styles.streamsHeroContent}>
|
||||||
{currentEpisode ? (
|
{currentEpisode ? (
|
||||||
<Animated.View style={styles.streamsHeroInfo}>
|
<View style={styles.streamsHeroInfo}>
|
||||||
<Text style={styles.streamsHeroEpisodeNumber}>{currentEpisode.episodeString}</Text>
|
<AnimatedText style={styles.streamsHeroEpisodeNumber} delay={50}>
|
||||||
<Text style={styles.streamsHeroTitle} numberOfLines={1}>
|
{currentEpisode.episodeString}
|
||||||
|
</AnimatedText>
|
||||||
|
<AnimatedText style={styles.streamsHeroTitle} numberOfLines={1} delay={100}>
|
||||||
{currentEpisode.name}
|
{currentEpisode.name}
|
||||||
</Text>
|
</AnimatedText>
|
||||||
{!!currentEpisode.overview && (
|
{!!currentEpisode.overview && (
|
||||||
<Animated.View>
|
<AnimatedView delay={150}>
|
||||||
<Text style={styles.streamsHeroOverview} numberOfLines={2}>
|
<Text style={styles.streamsHeroOverview} numberOfLines={2}>
|
||||||
{currentEpisode.overview}
|
{currentEpisode.overview}
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</AnimatedView>
|
||||||
)}
|
)}
|
||||||
<Animated.View style={styles.streamsHeroMeta}>
|
<AnimatedView style={styles.streamsHeroMeta} delay={200}>
|
||||||
<Text style={styles.streamsHeroReleased}>
|
<Text style={styles.streamsHeroReleased}>
|
||||||
{tmdbService.formatAirDate(currentEpisode.air_date)}
|
{tmdbService.formatAirDate(currentEpisode.air_date)}
|
||||||
</Text>
|
</Text>
|
||||||
{effectiveEpisodeVote > 0 && (
|
{effectiveEpisodeVote > 0 && (
|
||||||
<Animated.View style={styles.streamsHeroRating}>
|
<View style={styles.streamsHeroRating}>
|
||||||
<Image source={{ uri: TMDB_LOGO }} style={styles.tmdbLogo} contentFit="contain" />
|
<Image source={{ uri: TMDB_LOGO }} style={styles.tmdbLogo} contentFit="contain" />
|
||||||
<Text style={styles.streamsHeroRatingText}>
|
<Text style={styles.streamsHeroRatingText}>
|
||||||
{effectiveEpisodeVote.toFixed(1)}
|
{effectiveEpisodeVote.toFixed(1)}
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{!!effectiveEpisodeRuntime && (
|
{!!effectiveEpisodeRuntime && (
|
||||||
<Animated.View style={styles.streamsHeroRuntime}>
|
<View style={styles.streamsHeroRuntime}>
|
||||||
<MaterialIcons name="schedule" size={16} color={colors.mediumEmphasis} />
|
<MaterialIcons name="schedule" size={16} color={colors.mediumEmphasis} />
|
||||||
<Text style={styles.streamsHeroRuntimeText}>
|
<Text style={styles.streamsHeroRuntimeText}>
|
||||||
{effectiveEpisodeRuntime >= 60
|
{effectiveEpisodeRuntime >= 60
|
||||||
? `${Math.floor(effectiveEpisodeRuntime / 60)}h ${effectiveEpisodeRuntime % 60}m`
|
? `${Math.floor(effectiveEpisodeRuntime / 60)}h ${effectiveEpisodeRuntime % 60}m`
|
||||||
: `${effectiveEpisodeRuntime}m`}
|
: `${effectiveEpisodeRuntime}m`}
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</AnimatedView>
|
||||||
</Animated.View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
// Placeholder to reserve space and avoid layout shift while loading
|
// Placeholder to reserve space and avoid layout shift while loading
|
||||||
<View style={{ width: '100%', height: 120 }} />
|
<View style={{ width: '100%', height: 120 }} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
</Animated.View>
|
</View>
|
||||||
</Animated.View>
|
</View>
|
||||||
</Animated.View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={[
|
<View style={[
|
||||||
styles.streamsMainContent,
|
styles.streamsMainContent,
|
||||||
type === 'movie' && styles.streamsMainContentMovie
|
type === 'movie' && styles.streamsMainContentMovie
|
||||||
]}>
|
]}>
|
||||||
<Animated.View style={[styles.filterContainer, filterStyle]}>
|
<View style={[styles.filterContainer]}>
|
||||||
{Object.keys(streams).length > 0 && (
|
{Object.keys(streams).length > 0 && (
|
||||||
<ProviderFilter
|
<ProviderFilter
|
||||||
selectedProvider={selectedProvider}
|
selectedProvider={selectedProvider}
|
||||||
|
|
@ -1625,11 +1664,11 @@ export const StreamsScreen = () => {
|
||||||
theme={currentTheme}
|
theme={currentTheme}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</View>
|
||||||
|
|
||||||
{/* Active Scrapers Status */}
|
{/* Active Scrapers Status */}
|
||||||
{activeFetchingScrapers.length > 0 && (
|
{activeFetchingScrapers.length > 0 && (
|
||||||
<Animated.View
|
<View
|
||||||
style={styles.activeScrapersContainer}
|
style={styles.activeScrapersContainer}
|
||||||
>
|
>
|
||||||
<Text style={styles.activeScrapersTitle}>Fetching from:</Text>
|
<Text style={styles.activeScrapersTitle}>Fetching from:</Text>
|
||||||
|
|
@ -1638,13 +1677,12 @@ export const StreamsScreen = () => {
|
||||||
<PulsingChip key={scraperName} text={scraperName} delay={index * 200} />
|
<PulsingChip key={scraperName} text={scraperName} delay={index * 200} />
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Update the streams/loading state display logic */}
|
{/* Update the streams/loading state display logic */}
|
||||||
{ showNoSourcesError ? (
|
{ showNoSourcesError ? (
|
||||||
<Animated.View
|
<View
|
||||||
entering={FadeIn.duration(300)}
|
|
||||||
style={styles.noStreams}
|
style={styles.noStreams}
|
||||||
>
|
>
|
||||||
<MaterialIcons name="error-outline" size={48} color={colors.textMuted} />
|
<MaterialIcons name="error-outline" size={48} color={colors.textMuted} />
|
||||||
|
|
@ -1658,46 +1696,46 @@ export const StreamsScreen = () => {
|
||||||
>
|
>
|
||||||
<Text style={styles.addSourcesButtonText}>Add Sources</Text>
|
<Text style={styles.addSourcesButtonText}>Add Sources</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Animated.View>
|
</View>
|
||||||
) : streamsEmpty ? (
|
) : streamsEmpty ? (
|
||||||
showInitialLoading ? (
|
showInitialLoading ? (
|
||||||
<Animated.View
|
<View
|
||||||
style={styles.loadingContainer}
|
style={styles.loadingContainer}
|
||||||
>
|
>
|
||||||
<ActivityIndicator size="large" color={colors.primary} />
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
<Text style={styles.loadingText}>
|
<Text style={styles.loadingText}>
|
||||||
{isAutoplayWaiting ? 'Finding best stream for autoplay...' : 'Finding available streams...'}
|
{isAutoplayWaiting ? 'Finding best stream for autoplay...' : 'Finding available streams...'}
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</View>
|
||||||
) : showStillFetching ? (
|
) : showStillFetching ? (
|
||||||
<Animated.View
|
<View
|
||||||
style={styles.loadingContainer}
|
style={styles.loadingContainer}
|
||||||
>
|
>
|
||||||
<MaterialIcons name="hourglass-bottom" size={32} color={colors.primary} />
|
<MaterialIcons name="hourglass-bottom" size={32} color={colors.primary} />
|
||||||
<Text style={styles.loadingText}>Still fetching streams…</Text>
|
<Text style={styles.loadingText}>Still fetching streams…</Text>
|
||||||
</Animated.View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
// No streams and not loading = no streams available
|
// No streams and not loading = no streams available
|
||||||
<Animated.View
|
<View
|
||||||
style={styles.noStreams}
|
style={styles.noStreams}
|
||||||
>
|
>
|
||||||
<MaterialIcons name="error-outline" size={48} color={colors.textMuted} />
|
<MaterialIcons name="error-outline" size={48} color={colors.textMuted} />
|
||||||
<Text style={styles.noStreamsText}>No streams available</Text>
|
<Text style={styles.noStreamsText}>No streams available</Text>
|
||||||
</Animated.View>
|
</View>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
// Show streams immediately when available, even if still loading others
|
// Show streams immediately when available, even if still loading others
|
||||||
<View collapsable={false} style={{ flex: 1 }}>
|
<View collapsable={false} style={{ flex: 1 }}>
|
||||||
{/* Show autoplay loading overlay if waiting for autoplay */}
|
{/* Show autoplay loading overlay if waiting for autoplay */}
|
||||||
{isAutoplayWaiting && !autoplayTriggered && (
|
{isAutoplayWaiting && !autoplayTriggered && (
|
||||||
<Animated.View
|
<View
|
||||||
style={styles.autoplayOverlay}
|
style={styles.autoplayOverlay}
|
||||||
>
|
>
|
||||||
<View style={styles.autoplayIndicator}>
|
<View style={styles.autoplayIndicator}>
|
||||||
<ActivityIndicator size="small" color={colors.primary} />
|
<ActivityIndicator size="small" color={colors.primary} />
|
||||||
<Text style={styles.autoplayText}>Starting best stream...</Text>
|
<Text style={styles.autoplayText}>Starting best stream...</Text>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SectionList
|
<SectionList
|
||||||
|
|
@ -2134,17 +2172,6 @@ const createStyles = (colors: any) => StyleSheet.create({
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
transitionOverlay: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: colors.darkBackground,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
zIndex: 9999,
|
|
||||||
},
|
|
||||||
sectionHeaderContainer: {
|
sectionHeaderContainer: {
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue