Implement source selection feature in VideoPlayer and enhance stream handling

This update introduces a new SourcesModal in the VideoPlayer component, allowing users to select from available streams. The VideoPlayer now manages stream changes more effectively, including handling pending seeks and updating the current stream URL. Additionally, the PlayerControls component has been updated to include a button for changing the source. Styling improvements have been made for better visual feedback during source changes, enhancing the overall user experience.
This commit is contained in:
tapframe 2025-06-10 02:43:48 +05:30
parent 4deb343c5f
commit d5f71ecb62
9 changed files with 581 additions and 155 deletions

View file

@ -29,6 +29,7 @@ import AudioTrackModal from './modals/AudioTrackModal';
import ResumeOverlay from './modals/ResumeOverlay';
import PlayerControls from './controls/PlayerControls';
import CustomSubtitles from './subtitles/CustomSubtitles';
import SourcesModal from './modals/SourcesModal';
const VideoPlayer: React.FC = () => {
const navigation = useNavigation();
@ -46,7 +47,8 @@ const VideoPlayer: React.FC = () => {
id,
type,
episodeId,
imdbId
imdbId,
availableStreams: passedAvailableStreams
} = route.params;
safeDebugLog("Component mounted with props", {
@ -112,7 +114,15 @@ const VideoPlayer: React.FC = () => {
const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]);
const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState<boolean>(false);
const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState<boolean>(false);
const isMounted = useRef(true);
const [showSourcesModal, setShowSourcesModal] = useState<boolean>(false);
const [availableStreams, setAvailableStreams] = useState<{ [providerId: string]: { streams: any[]; addonName: string } }>(passedAvailableStreams || {});
const [currentStreamUrl, setCurrentStreamUrl] = useState<string>(uri);
const [isChangingSource, setIsChangingSource] = useState<boolean>(false);
const [pendingSeek, setPendingSeek] = useState<{ position: number; shouldPlay: boolean } | null>(null);
const [currentQuality, setCurrentQuality] = useState<string | undefined>(quality);
const [currentStreamProvider, setCurrentStreamProvider] = useState<string | undefined>(streamProvider);
const [currentStreamName, setCurrentStreamName] = useState<string | undefined>(undefined);
const isMounted = useRef(true);
const calculateVideoStyles = (videoWidth: number, videoHeight: number, screenWidth: number, screenHeight: number) => {
return {
@ -728,6 +738,123 @@ const VideoPlayer: React.FC = () => {
saveSubtitleSize(newSize);
};
useEffect(() => {
if (pendingSeek && isPlayerReady && isVideoLoaded && duration > 0) {
logger.log(`[VideoPlayer] Player ready after source change, seeking to position: ${pendingSeek.position}s out of ${duration}s total`);
if (pendingSeek.position > 0 && vlcRef.current) {
// Wait longer for the player to be fully ready and stable
setTimeout(() => {
if (vlcRef.current && duration > 0 && pendingSeek) {
logger.log(`[VideoPlayer] Executing seek to ${pendingSeek.position}s`);
// Use our existing seekToTime function which handles VLC methods properly
seekToTime(pendingSeek.position);
// Also update the current time state to reflect the seek
setCurrentTime(pendingSeek.position);
// Resume playback if it was playing before the source change
if (pendingSeek.shouldPlay) {
setTimeout(() => {
logger.log('[VideoPlayer] Resuming playback after seek');
setPaused(false);
if (vlcRef.current && typeof vlcRef.current.play === 'function') {
vlcRef.current.play();
}
}, 700); // Wait longer for seek to complete properly
}
// Clean up after a reasonable delay
setTimeout(() => {
setPendingSeek(null);
setIsChangingSource(false);
}, 800);
}
}, 1500); // Increased delay to ensure player is fully stable
} else {
// No seeking needed, just resume playback if it was playing
if (pendingSeek.shouldPlay) {
setTimeout(() => {
logger.log('[VideoPlayer] No seek needed, just resuming playback');
setPaused(false);
if (vlcRef.current && typeof vlcRef.current.play === 'function') {
vlcRef.current.play();
}
}, 500);
}
setTimeout(() => {
setPendingSeek(null);
setIsChangingSource(false);
}, 600);
}
}
}, [pendingSeek, isPlayerReady, isVideoLoaded, duration]);
const handleSelectStream = async (newStream: any) => {
if (newStream.url === currentStreamUrl) {
setShowSourcesModal(false);
return;
}
setIsChangingSource(true);
setShowSourcesModal(false);
try {
// Save current state
const savedPosition = currentTime;
const wasPlaying = !paused;
logger.log(`[VideoPlayer] Changing source from ${currentStreamUrl} to ${newStream.url}`);
logger.log(`[VideoPlayer] Saved position: ${savedPosition}, was playing: ${wasPlaying}`);
// Extract quality and provider information from the new stream
let newQuality = newStream.quality;
if (!newQuality && newStream.title) {
// Try to extract quality from title (e.g., "1080p", "720p")
const qualityMatch = newStream.title.match(/(\d+)p/);
newQuality = qualityMatch ? qualityMatch[0] : undefined; // Use [0] to get full match like "1080p"
}
// For provider, try multiple fields
const newProvider = newStream.addonName || newStream.name || newStream.addon || 'Unknown';
// For stream name, prioritize the stream name over title
const newStreamName = newStream.name || newStream.title || 'Unknown Stream';
logger.log(`[VideoPlayer] Stream object:`, newStream);
logger.log(`[VideoPlayer] Extracted - Quality: ${newQuality}, Provider: ${newProvider}, Stream Name: ${newStreamName}`);
logger.log(`[VideoPlayer] Available fields - quality: ${newStream.quality}, title: ${newStream.title}, addonName: ${newStream.addonName}, name: ${newStream.name}, addon: ${newStream.addon}`);
// Stop current playback
if (vlcRef.current) {
vlcRef.current.pause && vlcRef.current.pause();
}
setPaused(true);
// Set pending seek state
setPendingSeek({ position: savedPosition, shouldPlay: wasPlaying });
// Update the stream URL and details immediately
setCurrentStreamUrl(newStream.url);
setCurrentQuality(newQuality);
setCurrentStreamProvider(newProvider);
setCurrentStreamName(newStreamName);
// Reset player state for new source
setCurrentTime(0);
setDuration(0);
setIsPlayerReady(false);
setIsVideoLoaded(false);
} catch (error) {
logger.error('[VideoPlayer] Error changing source:', error);
setPendingSeek(null);
setIsChangingSource(false);
}
};
return (
<View style={[styles.container, {
width: screenDimensions.width,
@ -762,6 +889,27 @@ const VideoPlayer: React.FC = () => {
</View>
</Animated.View>
{/* Source Change Loading Overlay */}
{isChangingSource && (
<Animated.View
style={[
styles.sourceChangeOverlay,
{
width: screenDimensions.width,
height: screenDimensions.height,
opacity: fadeAnim,
}
]}
pointerEvents="auto"
>
<View style={styles.sourceChangeContent}>
<ActivityIndicator size="large" color="#E50914" />
<Text style={styles.sourceChangeText}>Changing source...</Text>
<Text style={styles.sourceChangeSubtext}>Please wait while we load the new stream</Text>
</View>
</Animated.View>
)}
<Animated.View
style={[
styles.videoPlayerContainer,
@ -814,7 +962,7 @@ const VideoPlayer: React.FC = () => {
],
}}
source={{
uri: uri,
uri: currentStreamUrl,
initOptions: [
'--rtsp-tcp',
'--network-caching=150',
@ -849,21 +997,23 @@ const VideoPlayer: React.FC = () => {
episodeTitle={episodeTitle}
season={season}
episode={episode}
quality={quality}
quality={currentQuality || quality}
year={year}
streamProvider={streamProvider}
streamProvider={currentStreamProvider || streamProvider}
currentTime={currentTime}
duration={duration}
playbackSpeed={playbackSpeed}
zoomScale={zoomScale}
vlcAudioTracks={vlcAudioTracks}
selectedAudioTrack={selectedAudioTrack}
availableStreams={availableStreams}
togglePlayback={togglePlayback}
skip={skip}
handleClose={handleClose}
cycleAspectRatio={cycleAspectRatio}
setShowAudioModal={setShowAudioModal}
setShowSubtitleModal={setShowSubtitleModal}
setShowSourcesModal={setShowSourcesModal}
progressBarRef={progressBarRef}
progressAnim={progressAnim}
handleProgressBarTouch={handleProgressBarTouch}
@ -923,6 +1073,15 @@ const VideoPlayer: React.FC = () => {
increaseSubtitleSize={increaseSubtitleSize}
decreaseSubtitleSize={decreaseSubtitleSize}
/>
<SourcesModal
showSourcesModal={showSourcesModal}
setShowSourcesModal={setShowSourcesModal}
availableStreams={availableStreams}
currentStreamUrl={currentStreamUrl}
onSelectStream={handleSelectStream}
isChangingSource={isChangingSource}
/>
</View>
);
};

View file

@ -22,12 +22,14 @@ interface PlayerControlsProps {
zoomScale: number;
vlcAudioTracks: Array<{id: number, name: string, language?: string}>;
selectedAudioTrack: number | null;
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
togglePlayback: () => void;
skip: (seconds: number) => void;
handleClose: () => void;
cycleAspectRatio: () => void;
setShowAudioModal: (show: boolean) => void;
setShowSubtitleModal: (show: boolean) => void;
setShowSourcesModal?: (show: boolean) => void;
progressBarRef: React.RefObject<View>;
progressAnim: Animated.Value;
handleProgressBarTouch: (event: any) => void;
@ -55,12 +57,14 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
zoomScale,
vlcAudioTracks,
selectedAudioTrack,
availableStreams,
togglePlayback,
skip,
handleClose,
cycleAspectRatio,
setShowAudioModal,
setShowSubtitleModal,
setShowSourcesModal,
progressBarRef,
progressAnim,
handleProgressBarTouch,
@ -206,6 +210,19 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
Subtitles
</Text>
</TouchableOpacity>
{/* Change Source Button */}
{setShowSourcesModal && (
<TouchableOpacity
style={styles.bottomButton}
onPress={() => setShowSourcesModal(true)}
>
<Ionicons name="swap-horizontal" size={20} color="white" />
<Text style={styles.bottomButtonText}>
Change Source
</Text>
</TouchableOpacity>
)}
</View>
</View>
</LinearGradient>

View file

@ -0,0 +1,160 @@
import React from 'react';
import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator } from 'react-native';
import { Ionicons, MaterialIcons } from '@expo/vector-icons';
import { styles } from '../utils/playerStyles';
import { Stream } from '../../../types/streams';
import QualityBadge from '../../metadata/QualityBadge';
interface SourcesModalProps {
showSourcesModal: boolean;
setShowSourcesModal: (show: boolean) => void;
availableStreams: { [providerId: string]: { streams: Stream[]; addonName: string } };
currentStreamUrl: string;
onSelectStream: (stream: Stream) => void;
isChangingSource: boolean;
}
const SourcesModal: React.FC<SourcesModalProps> = ({
showSourcesModal,
setShowSourcesModal,
availableStreams,
currentStreamUrl,
onSelectStream,
isChangingSource,
}) => {
if (!showSourcesModal) return null;
const sortedProviders = Object.entries(availableStreams).sort(([a], [b]) => {
// Put HDRezka first
if (a === 'hdrezka') return -1;
if (b === 'hdrezka') return 1;
return 0;
});
const handleStreamSelect = (stream: Stream) => {
if (stream.url !== currentStreamUrl && !isChangingSource) {
onSelectStream(stream);
}
};
const getQualityFromTitle = (title?: string): string | null => {
if (!title) return null;
const match = title.match(/(\d+)p/);
return match ? match[1] : null;
};
const isStreamSelected = (stream: Stream): boolean => {
return stream.url === currentStreamUrl;
};
return (
<View style={styles.modalOverlay}>
<View style={styles.sourcesModal}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Choose Source</Text>
<TouchableOpacity
style={styles.modalCloseButton}
onPress={() => setShowSourcesModal(false)}
>
<MaterialIcons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<ScrollView style={styles.sourcesScrollView} showsVerticalScrollIndicator={false}>
{sortedProviders.map(([providerId, { streams, addonName }]) => (
<View key={providerId} style={styles.sourceProviderSection}>
<Text style={styles.sourceProviderTitle}>{addonName}</Text>
{streams.map((stream, index) => {
const quality = getQualityFromTitle(stream.title);
const isSelected = isStreamSelected(stream);
const isHDR = stream.title?.toLowerCase().includes('hdr');
const isDolby = stream.title?.toLowerCase().includes('dolby') || stream.title?.includes('DV');
const size = stream.title?.match(/💾\s*([\d.]+\s*[GM]B)/)?.[1];
const isDebrid = stream.behaviorHints?.cached;
const isHDRezka = providerId === 'hdrezka';
return (
<TouchableOpacity
key={`${stream.url}-${index}`}
style={[
styles.sourceStreamItem,
isSelected && styles.sourceStreamItemSelected
]}
onPress={() => handleStreamSelect(stream)}
disabled={isChangingSource || isSelected}
activeOpacity={0.7}
>
<View style={styles.sourceStreamDetails}>
<View style={styles.sourceStreamTitleRow}>
<Text style={[
styles.sourceStreamTitle,
isSelected && styles.sourceStreamTitleSelected
]}>
{isHDRezka ? `HDRezka ${stream.title}` : (stream.name || stream.title || 'Unnamed Stream')}
</Text>
{isSelected && (
<View style={styles.currentSourceBadge}>
<MaterialIcons name="play-arrow" size={16} color="#E50914" />
<Text style={styles.currentSourceText}>Current</Text>
</View>
)}
{isChangingSource && isSelected && (
<ActivityIndicator size="small" color="#E50914" style={{ marginLeft: 8 }} />
)}
</View>
{!isHDRezka && stream.title && stream.title !== stream.name && (
<Text style={styles.sourceStreamSubtitle}>{stream.title}</Text>
)}
<View style={styles.sourceStreamMeta}>
{quality && quality >= "720" && (
<QualityBadge type="HD" />
)}
{isDolby && (
<QualityBadge type="VISION" />
)}
{size && (
<View style={styles.sourceChip}>
<Text style={styles.sourceChipText}>{size}</Text>
</View>
)}
{isDebrid && (
<View style={[styles.sourceChip, styles.debridChip]}>
<Text style={styles.sourceChipText}>DEBRID</Text>
</View>
)}
{isHDRezka && (
<View style={[styles.sourceChip, styles.hdrezkaChip]}>
<Text style={styles.sourceChipText}>HDREZKA</Text>
</View>
)}
</View>
</View>
<View style={styles.sourceStreamAction}>
{isSelected ? (
<MaterialIcons name="check-circle" size={24} color="#E50914" />
) : (
<MaterialIcons name="play-arrow" size={24} color="rgba(255,255,255,0.7)" />
)}
</View>
</TouchableOpacity>
);
})}
</View>
))}
</ScrollView>
</View>
</View>
);
};
export default SourcesModal;

View file

@ -76,15 +76,16 @@ export const styles = StyleSheet.create({
marginRight: 8,
},
qualityBadge: {
backgroundColor: '#E50914',
paddingHorizontal: 6,
backgroundColor: 'rgba(229, 9, 20, 0.2)',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
marginRight: 8,
marginBottom: 4,
},
qualityText: {
color: 'white',
fontSize: 10,
color: '#E50914',
fontSize: 11,
fontWeight: 'bold',
},
providerText: {
@ -189,9 +190,10 @@ export const styles = StyleSheet.create({
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.9)',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
padding: 20,
},
modalContent: {
width: '80%',
@ -207,16 +209,15 @@ export const styles = StyleSheet.create({
shadowRadius: 5,
},
modalHeader: {
padding: 16,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
borderBottomWidth: 1,
borderBottomColor: '#333',
marginBottom: 20,
paddingHorizontal: 4,
},
modalTitle: {
color: 'white',
fontSize: 18,
fontSize: 20,
fontWeight: 'bold',
},
trackList: {
@ -764,4 +765,223 @@ export const styles = StyleSheet.create({
alignItems: 'center',
zIndex: 9999,
},
// Sources Modal Styles
sourcesModal: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.9)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
sourcesContainer: {
backgroundColor: 'rgba(20, 20, 20, 0.98)',
borderRadius: 12,
width: '100%',
maxWidth: 500,
maxHeight: '80%',
paddingVertical: 20,
paddingHorizontal: 16,
},
sourcesHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
paddingHorizontal: 4,
},
sourcesTitle: {
color: 'white',
fontSize: 20,
fontWeight: 'bold',
},
modalCloseButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
},
sourcesScrollView: {
maxHeight: 400,
},
sourceItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 16,
paddingHorizontal: 12,
borderRadius: 8,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
marginBottom: 8,
},
currentSourceItem: {
backgroundColor: 'rgba(229, 9, 20, 0.2)',
borderWidth: 1,
borderColor: 'rgba(229, 9, 20, 0.5)',
},
sourceInfo: {
flex: 1,
marginLeft: 12,
},
sourceTitle: {
color: 'white',
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
sourceDetails: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
},
sourceDetailText: {
color: '#888',
fontSize: 12,
marginRight: 8,
marginBottom: 4,
},
currentStreamBadge: {
backgroundColor: 'rgba(0, 255, 0, 0.2)',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
marginRight: 8,
marginBottom: 4,
},
currentStreamText: {
color: '#00FF00',
fontSize: 11,
fontWeight: 'bold',
},
switchingSourceOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 9999,
},
switchingContent: {
alignItems: 'center',
backgroundColor: 'rgba(20, 20, 20, 0.9)',
padding: 30,
borderRadius: 12,
minWidth: 200,
},
switchingText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
marginTop: 12,
textAlign: 'center',
},
// Additional SourcesModal styles
sourceProviderSection: {
marginBottom: 20,
},
sourceProviderTitle: {
color: 'rgba(255, 255, 255, 0.8)',
fontSize: 14,
fontWeight: '600',
marginBottom: 12,
paddingHorizontal: 4,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
sourceStreamItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 12,
borderRadius: 8,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
marginBottom: 8,
},
sourceStreamItemSelected: {
backgroundColor: 'rgba(229, 9, 20, 0.2)',
borderWidth: 1,
borderColor: 'rgba(229, 9, 20, 0.5)',
},
sourceStreamDetails: {
flex: 1,
},
sourceStreamTitleRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
},
sourceStreamTitle: {
color: 'white',
fontSize: 16,
fontWeight: '600',
flex: 1,
},
sourceStreamTitleSelected: {
color: '#E50914',
},
sourceStreamSubtitle: {
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 14,
marginBottom: 6,
},
sourceStreamMeta: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
},
sourceChip: {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
marginRight: 6,
marginBottom: 4,
},
sourceChipText: {
color: 'rgba(255, 255, 255, 0.8)',
fontSize: 11,
fontWeight: 'bold',
},
debridChip: {
backgroundColor: 'rgba(0, 255, 0, 0.2)',
},
hdrezkaChip: {
backgroundColor: 'rgba(255, 165, 0, 0.2)',
},
sourceStreamAction: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
},
// Source Change Loading Overlay
sourceChangeOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.9)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 5000,
},
sourceChangeContent: {
alignItems: 'center',
padding: 30,
},
sourceChangeText: {
color: '#E50914',
fontSize: 18,
fontWeight: 'bold',
marginTop: 15,
textAlign: 'center',
},
sourceChangeSubtext: {
color: 'rgba(255, 255, 255, 0.8)',
fontSize: 14,
marginTop: 8,
textAlign: 'center',
},
});

View file

@ -35,7 +35,6 @@ export interface AppSettings {
logoSourcePreference: 'metahub' | 'tmdb'; // Preferred source for title logos
tmdbLanguagePreference: string; // Preferred language for TMDB logos (ISO 639-1 code)
enableInternalProviders: boolean; // Toggle for internal providers like HDRezka
autoPlayFirstStream: boolean; // Auto-play first stream without showing streams selection
}
export const DEFAULT_SETTINGS: AppSettings = {
@ -53,7 +52,6 @@ export const DEFAULT_SETTINGS: AppSettings = {
logoSourcePreference: 'metahub', // Default to Metahub as first source
tmdbLanguagePreference: 'en', // Default to English
enableInternalProviders: true, // Enable internal providers by default
autoPlayFirstStream: false, // Default to false to maintain existing behavior
};
const SETTINGS_STORAGE_KEY = 'app_settings';

View file

@ -78,6 +78,7 @@ export type RootStackParamList = {
type?: string;
episodeId?: string;
imdbId?: string;
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
};
Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string };
Credits: { mediaId: string; mediaType: string };

View file

@ -12,7 +12,6 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRoute, useNavigation } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import * as Haptics from 'expo-haptics';
import * as ScreenOrientation from 'expo-screen-orientation';
import { useTheme } from '../contexts/ThemeContext';
import { useMetadata } from '../hooks/useMetadata';
import { CastSection } from '../components/metadata/CastSection';
@ -21,7 +20,6 @@ import { MovieContent } from '../components/metadata/MovieContent';
import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection';
import { RatingsSection } from '../components/metadata/RatingsSection';
import { RouteParams, Episode } from '../types/metadata';
import { Stream, GroupedStreams } from '../types/streams';
import Animated, {
useAnimatedStyle,
interpolate,
@ -77,10 +75,6 @@ const MetadataScreen: React.FC = () => {
loadingRecommendations,
setMetadata,
imdbId,
loadStreams,
loadEpisodeStreams,
groupedStreams,
episodeStreams,
} = useMetadata({ id, type });
// Optimized hooks with memoization
@ -113,127 +107,8 @@ const MetadataScreen: React.FC = () => {
handleSeasonChange(seasonNumber);
}, [handleSeasonChange]);
// Helper function to get the first available stream from grouped streams
const getFirstAvailableStream = useCallback((streams: GroupedStreams): Stream | null => {
const providers = Object.values(streams);
for (const provider of providers) {
if (provider.streams && provider.streams.length > 0) {
// Try to find a cached stream first
const cachedStream = provider.streams.find(stream =>
stream.behaviorHints?.cached === true
);
if (cachedStream) {
return cachedStream;
}
// Otherwise return the first stream
return provider.streams[0];
}
}
return null;
}, []);
const handleShowStreams = useCallback(async () => {
const handleShowStreams = useCallback(() => {
const { watchProgress } = watchProgressData;
// Check if auto-play is enabled
if (settings.autoPlayFirstStream) {
try {
console.log('Auto-play enabled, attempting to load streams...');
// Determine the target episode for series
let targetEpisodeId: string | undefined;
if (type === 'series') {
targetEpisodeId = watchProgress?.episodeId || episodeId || (episodes.length > 0 ?
(episodes[0].stremioId || `${id}:${episodes[0].season_number}:${episodes[0].episode_number}`) : undefined);
}
// Load streams without locking orientation yet
let streamsLoaded = false;
if (type === 'series' && targetEpisodeId) {
console.log('Loading episode streams for:', targetEpisodeId);
await loadEpisodeStreams(targetEpisodeId);
streamsLoaded = true;
} else if (type === 'movie') {
console.log('Loading movie streams...');
await loadStreams();
streamsLoaded = true;
}
if (streamsLoaded) {
// Wait a bit longer for streams to be processed and state to update
console.log('Waiting for streams to be processed...');
await new Promise(resolve => setTimeout(resolve, 3000));
// Check if we have any streams available
const availableStreams = type === 'series' ? episodeStreams : groupedStreams;
console.log('Available streams:', Object.keys(availableStreams));
const firstStream = getFirstAvailableStream(availableStreams);
if (firstStream) {
console.log('Found stream, navigating to player:', firstStream);
// Now lock orientation to landscape before navigation
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
await new Promise(resolve => setTimeout(resolve, 200));
if (type === 'series' && targetEpisodeId) {
// Get episode details for navigation
const targetEpisode = episodes.find(ep =>
ep.stremioId === targetEpisodeId ||
`${id}:${ep.season_number}:${ep.episode_number}` === targetEpisodeId
);
// Navigate directly to player with the first stream
navigation.navigate('Player', {
uri: firstStream.url,
title: metadata?.name || 'Unknown',
season: targetEpisode?.season_number,
episode: targetEpisode?.episode_number,
episodeTitle: targetEpisode?.name,
quality: firstStream.title?.match(/(\d+)p/)?.[1] || 'Unknown',
year: metadata?.year,
streamProvider: firstStream.name || 'Unknown',
id,
type,
episodeId: targetEpisodeId,
imdbId: imdbId || id,
});
return;
} else if (type === 'movie') {
// Navigate directly to player with the first stream
navigation.navigate('Player', {
uri: firstStream.url,
title: metadata?.name || 'Unknown',
quality: firstStream.title?.match(/(\d+)p/)?.[1] || 'Unknown',
year: metadata?.year,
streamProvider: firstStream.name || 'Unknown',
id,
type,
imdbId: imdbId || id,
});
return;
}
} else {
console.log('No streams found after waiting, disabling auto-play for this session');
// Don't fall back to streams screen, just show an alert
alert('No streams available for auto-play. Please try selecting streams manually.');
return;
}
}
console.log('Auto-play failed, falling back to manual selection');
} catch (error) {
console.error('Auto-play failed with error:', error);
// Don't fall back on error, just show alert
alert('Auto-play failed. Please try selecting streams manually.');
return;
}
}
// Normal behavior: navigate to streams screen (only if auto-play is disabled or not attempted)
console.log('Navigating to streams screen (normal flow)');
if (type === 'series') {
const targetEpisodeId = watchProgress?.episodeId || episodeId || (episodes.length > 0 ?
(episodes[0].stremioId || `${id}:${episodes[0].season_number}:${episodes[0].episode_number}`) : undefined);
@ -244,7 +119,7 @@ const MetadataScreen: React.FC = () => {
}
}
navigation.navigate('Streams', { id, type, episodeId });
}, [settings.autoPlayFirstStream, navigation, id, type, episodes, episodeId, watchProgressData, metadata, loadEpisodeStreams, loadStreams, episodeStreams, groupedStreams, imdbId, getFirstAvailableStream]);
}, [navigation, id, type, episodes, episodeId, watchProgressData.watchProgress]);
const handleEpisodeSelect = useCallback((episode: Episode) => {
const episodeId = episode.stremioId || `${id}:${episode.season_number}:${episode.episode_number}`;

View file

@ -462,17 +462,6 @@ const SettingsScreen: React.FC = () => {
icon="play-arrow"
renderControl={ChevronRight}
onPress={() => navigation.navigate('PlayerSettings')}
/>
<SettingItem
title="Auto-play First Stream"
description="Automatically play the first available stream without showing stream selection"
icon="auto-fix-high"
renderControl={() => (
<CustomSwitch
value={settings.autoPlayFirstStream}
onValueChange={(value) => updateSetting('autoPlayFirstStream', value)}
/>
)}
isLast={true}
/>
</SettingsCard>

View file

@ -519,6 +519,9 @@ export const StreamsScreen = () => {
// Small delay to ensure orientation is set before navigation
await new Promise(resolve => setTimeout(resolve, 100));
// Prepare available streams for the change source feature
const streamsToPass = type === 'series' ? episodeStreams : groupedStreams;
navigation.navigate('Player', {
uri: stream.url,
title: metadata?.name || '',
@ -532,10 +535,13 @@ export const StreamsScreen = () => {
type,
episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined,
imdbId: imdbId || undefined,
availableStreams: streamsToPass,
});
} catch (error) {
logger.error('[StreamsScreen] Error locking orientation before navigation:', error);
// Fallback: navigate anyway
const streamsToPass = type === 'series' ? episodeStreams : groupedStreams;
navigation.navigate('Player', {
uri: stream.url,
title: metadata?.name || '',
@ -549,9 +555,10 @@ export const StreamsScreen = () => {
type,
episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined,
imdbId: imdbId || undefined,
availableStreams: streamsToPass,
});
}
}, [metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId]);
}, [metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId, episodeStreams, groupedStreams]);
// Update handleStreamPress
const handleStreamPress = useCallback(async (stream: Stream) => {