Ios #14
9 changed files with 581 additions and 155 deletions
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
160
src/components/player/modals/SourcesModal.tsx
Normal file
160
src/components/player/modals/SourcesModal.tsx
Normal 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;
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue