Ios #14

Merged
tapframe merged 88 commits from ios into main 2025-06-20 13:54:29 +00:00
9 changed files with 581 additions and 155 deletions
Showing only changes of commit d5f71ecb62 - Show all commits

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) => {