diff --git a/src/components/streams/EpisodeHero.tsx b/src/components/streams/EpisodeHero.tsx
new file mode 100644
index 00000000..92d8d12f
--- /dev/null
+++ b/src/components/streams/EpisodeHero.tsx
@@ -0,0 +1,223 @@
+import React from 'react';
+import { StyleSheet, View, Text, ImageBackground } from 'react-native';
+import { MaterialIcons } from '@expo/vector-icons';
+import { Image } from 'expo-image';
+import { LinearGradient } from 'expo-linear-gradient';
+import Animated, { FadeIn } from 'react-native-reanimated';
+import { colors } from '../../styles/colors';
+import { tmdbService } from '../../services/tmdbService';
+
+const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906';
+
+interface EpisodeHeroProps {
+ currentEpisode: {
+ name: string;
+ overview?: string;
+ still_path?: string;
+ air_date?: string | null;
+ vote_average?: number;
+ runtime?: number;
+ episodeString: string;
+ season_number?: number;
+ episode_number?: number;
+ } | null;
+ metadata: {
+ poster?: string;
+ } | null;
+ animatedStyle: any;
+}
+
+const EpisodeHero = ({ currentEpisode, metadata, animatedStyle }: EpisodeHeroProps) => {
+ if (!currentEpisode) return null;
+
+ const episodeImage = currentEpisode.still_path
+ ? tmdbService.getImageUrl(currentEpisode.still_path, 'original')
+ : metadata?.poster || null;
+
+ // Format air date safely
+ const formattedAirDate = currentEpisode.air_date !== undefined
+ ? tmdbService.formatAirDate(currentEpisode.air_date)
+ : 'Unknown';
+
+ return (
+
+
+
+
+
+
+
+
+ {currentEpisode.episodeString}
+
+
+ {currentEpisode.name}
+
+ {currentEpisode.overview && (
+
+ {currentEpisode.overview}
+
+ )}
+
+
+ {formattedAirDate}
+
+ {currentEpisode.vote_average && currentEpisode.vote_average > 0 && (
+
+
+
+ {currentEpisode.vote_average.toFixed(1)}
+
+
+ )}
+ {currentEpisode.runtime && (
+
+
+
+ {currentEpisode.runtime >= 60
+ ? `${Math.floor(currentEpisode.runtime / 60)}h ${currentEpisode.runtime % 60}m`
+ : `${currentEpisode.runtime}m`}
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ streamsHeroContainer: {
+ width: '100%',
+ height: 300,
+ marginBottom: 0,
+ position: 'relative',
+ backgroundColor: colors.black,
+ pointerEvents: 'box-none',
+ },
+ streamsHeroBackground: {
+ width: '100%',
+ height: '100%',
+ backgroundColor: colors.black,
+ },
+ streamsHeroGradient: {
+ flex: 1,
+ justifyContent: 'flex-end',
+ padding: 16,
+ paddingBottom: 0,
+ },
+ streamsHeroContent: {
+ width: '100%',
+ },
+ streamsHeroInfo: {
+ width: '100%',
+ },
+ streamsHeroEpisodeNumber: {
+ color: colors.primary,
+ fontSize: 14,
+ fontWeight: 'bold',
+ marginBottom: 2,
+ textShadowColor: 'rgba(0,0,0,0.75)',
+ textShadowOffset: { width: 0, height: 1 },
+ textShadowRadius: 2,
+ },
+ streamsHeroTitle: {
+ color: colors.highEmphasis,
+ fontSize: 24,
+ fontWeight: 'bold',
+ marginBottom: 4,
+ textShadowColor: 'rgba(0,0,0,0.75)',
+ textShadowOffset: { width: 0, height: 1 },
+ textShadowRadius: 3,
+ },
+ streamsHeroOverview: {
+ color: colors.mediumEmphasis,
+ fontSize: 14,
+ lineHeight: 20,
+ marginBottom: 2,
+ textShadowColor: 'rgba(0,0,0,0.75)',
+ textShadowOffset: { width: 0, height: 1 },
+ textShadowRadius: 2,
+ },
+ streamsHeroMeta: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ marginTop: 0,
+ },
+ streamsHeroReleased: {
+ color: colors.mediumEmphasis,
+ fontSize: 14,
+ textShadowColor: 'rgba(0,0,0,0.75)',
+ textShadowOffset: { width: 0, height: 1 },
+ textShadowRadius: 2,
+ },
+ streamsHeroRating: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: 'rgba(0,0,0,0.7)',
+ paddingHorizontal: 6,
+ paddingVertical: 3,
+ borderRadius: 4,
+ marginTop: 0,
+ },
+ tmdbLogo: {
+ width: 20,
+ height: 14,
+ },
+ streamsHeroRatingText: {
+ color: '#01b4e4',
+ fontSize: 13,
+ fontWeight: '700',
+ marginLeft: 4,
+ },
+ streamsHeroRuntime: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 4,
+ backgroundColor: 'rgba(0,0,0,0.5)',
+ paddingHorizontal: 8,
+ paddingVertical: 4,
+ borderRadius: 6,
+ },
+ streamsHeroRuntimeText: {
+ color: colors.mediumEmphasis,
+ fontSize: 13,
+ fontWeight: '600',
+ },
+});
+
+export default React.memo(EpisodeHero);
\ No newline at end of file
diff --git a/src/components/streams/MovieHero.tsx b/src/components/streams/MovieHero.tsx
new file mode 100644
index 00000000..d05c6a83
--- /dev/null
+++ b/src/components/streams/MovieHero.tsx
@@ -0,0 +1,98 @@
+import React from 'react';
+import { StyleSheet, Text, View, ImageBackground, Dimensions, Platform } from 'react-native';
+import { LinearGradient } from 'expo-linear-gradient';
+import { Image } from 'expo-image';
+import Animated from 'react-native-reanimated';
+import { colors } from '../../styles/colors';
+
+const { width } = Dimensions.get('window');
+
+interface MovieHeroProps {
+ metadata: {
+ name: string;
+ logo?: string;
+ banner?: string;
+ poster?: string;
+ } | null;
+ animatedStyle: any;
+}
+
+const MovieHero = ({ metadata, animatedStyle }: MovieHeroProps) => {
+ if (!metadata) return null;
+
+ return (
+
+
+
+
+ {metadata.logo ? (
+
+ ) : (
+
+ {metadata.name}
+
+ )}
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ movieTitleContainer: {
+ width: '100%',
+ height: 180,
+ backgroundColor: colors.black,
+ pointerEvents: 'box-none',
+ },
+ movieTitleBackground: {
+ width: '100%',
+ height: '100%',
+ backgroundColor: colors.black,
+ },
+ movieTitleGradient: {
+ flex: 1,
+ justifyContent: 'center',
+ padding: 16,
+ },
+ movieTitleContent: {
+ width: '100%',
+ alignItems: 'center',
+ marginTop: Platform.OS === 'android' ? 35 : 45,
+ },
+ movieLogo: {
+ width: width * 0.6,
+ height: 70,
+ marginBottom: 8,
+ },
+ movieTitle: {
+ color: colors.highEmphasis,
+ fontSize: 28,
+ fontWeight: '900',
+ textAlign: 'center',
+ textShadowColor: 'rgba(0,0,0,0.75)',
+ textShadowOffset: { width: 0, height: 2 },
+ textShadowRadius: 4,
+ letterSpacing: -0.5,
+ },
+});
+
+export default React.memo(MovieHero);
\ No newline at end of file
diff --git a/src/components/streams/ProviderFilter.tsx b/src/components/streams/ProviderFilter.tsx
new file mode 100644
index 00000000..800051f9
--- /dev/null
+++ b/src/components/streams/ProviderFilter.tsx
@@ -0,0 +1,80 @@
+import React, { useCallback } from 'react';
+import { FlatList, StyleSheet, Text, TouchableOpacity } from 'react-native';
+import { colors } from '../../styles/colors';
+
+interface ProviderFilterProps {
+ selectedProvider: string;
+ providers: Array<{ id: string; name: string; }>;
+ onSelect: (id: string) => void;
+}
+
+const ProviderFilter = ({ selectedProvider, providers, onSelect }: ProviderFilterProps) => {
+ const renderItem = useCallback(({ item }: { item: { id: string; name: string } }) => (
+ onSelect(item.id)}
+ >
+
+ {item.name}
+
+
+ ), [selectedProvider, onSelect]);
+
+ return (
+ item.id}
+ horizontal
+ showsHorizontalScrollIndicator={false}
+ style={styles.filterScroll}
+ bounces={true}
+ overScrollMode="never"
+ decelerationRate="fast"
+ initialNumToRender={5}
+ maxToRenderPerBatch={3}
+ windowSize={3}
+ getItemLayout={(data, index) => ({
+ length: 100, // Approximate width of each item
+ offset: 100 * index,
+ index,
+ })}
+ />
+ );
+};
+
+const styles = StyleSheet.create({
+ filterScroll: {
+ flexGrow: 0,
+ },
+ filterChip: {
+ backgroundColor: colors.transparentLight,
+ paddingHorizontal: 16,
+ paddingVertical: 8,
+ borderRadius: 20,
+ marginRight: 8,
+ borderWidth: 1,
+ borderColor: colors.transparent,
+ },
+ filterChipSelected: {
+ backgroundColor: colors.transparentLight,
+ borderColor: colors.primary,
+ },
+ filterChipText: {
+ color: colors.text,
+ fontWeight: '500',
+ },
+ filterChipTextSelected: {
+ color: colors.primary,
+ fontWeight: 'bold',
+ },
+});
+
+export default React.memo(ProviderFilter);
\ No newline at end of file
diff --git a/src/components/streams/StreamCard.tsx b/src/components/streams/StreamCard.tsx
new file mode 100644
index 00000000..cb69824d
--- /dev/null
+++ b/src/components/streams/StreamCard.tsx
@@ -0,0 +1,181 @@
+import React from 'react';
+import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
+import { MaterialIcons } from '@expo/vector-icons';
+import { colors } from '../../styles/colors';
+import { Stream } from '../../types/metadata';
+import QualityBadge from '../metadata/QualityBadge';
+
+interface StreamCardProps {
+ stream: Stream;
+ onPress: () => void;
+ index: number;
+ isLoading?: boolean;
+ statusMessage?: string;
+}
+
+const StreamCard = ({ stream, onPress, index, isLoading, statusMessage }: StreamCardProps) => {
+ const quality = stream.title?.match(/(\d+)p/)?.[1] || null;
+ 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 displayTitle = stream.name || stream.title || 'Unnamed Stream';
+ const displayAddonName = stream.title || '';
+
+ return (
+
+
+
+
+
+ {displayTitle}
+
+ {displayAddonName && displayAddonName !== displayTitle && (
+
+ {displayAddonName}
+
+ )}
+
+
+ {/* Show loading indicator if stream is loading */}
+ {isLoading && (
+
+
+
+ {statusMessage || "Loading..."}
+
+
+ )}
+
+
+
+ {quality && quality >= "720" && (
+
+ )}
+
+ {isDolby && (
+
+ )}
+
+ {size && (
+
+ {size}
+
+ )}
+
+ {isDebrid && (
+
+ DEBRID
+
+ )}
+
+
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ streamCard: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ padding: 12,
+ borderRadius: 12,
+ marginBottom: 8,
+ minHeight: 70,
+ backgroundColor: colors.elevation1,
+ borderWidth: 1,
+ borderColor: 'rgba(255,255,255,0.05)',
+ width: '100%',
+ zIndex: 1,
+ },
+ streamCardLoading: {
+ opacity: 0.7,
+ },
+ streamDetails: {
+ flex: 1,
+ },
+ streamNameRow: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ justifyContent: 'space-between',
+ width: '100%',
+ flexWrap: 'wrap',
+ gap: 8
+ },
+ streamTitleContainer: {
+ flex: 1,
+ },
+ streamName: {
+ fontSize: 14,
+ fontWeight: '600',
+ marginBottom: 2,
+ lineHeight: 20,
+ color: colors.highEmphasis,
+ },
+ streamAddonName: {
+ fontSize: 13,
+ lineHeight: 18,
+ color: colors.mediumEmphasis,
+ marginBottom: 6,
+ },
+ streamMetaRow: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 4,
+ marginBottom: 6,
+ alignItems: 'center',
+ },
+ chip: {
+ paddingHorizontal: 8,
+ paddingVertical: 2,
+ borderRadius: 4,
+ marginRight: 4,
+ marginBottom: 4,
+ },
+ chipText: {
+ color: colors.highEmphasis,
+ fontSize: 12,
+ fontWeight: '600',
+ },
+ loadingIndicator: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 8,
+ paddingVertical: 2,
+ borderRadius: 12,
+ marginLeft: 8,
+ },
+ loadingText: {
+ color: colors.primary,
+ fontSize: 12,
+ marginLeft: 4,
+ fontWeight: '500',
+ },
+ streamAction: {
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ backgroundColor: colors.elevation2,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+});
+
+export default React.memo(StreamCard);
\ No newline at end of file
diff --git a/src/hooks/useStreamNavigation.ts b/src/hooks/useStreamNavigation.ts
new file mode 100644
index 00000000..b3925830
--- /dev/null
+++ b/src/hooks/useStreamNavigation.ts
@@ -0,0 +1,221 @@
+import { useCallback } from 'react';
+import { Platform, Linking } from 'react-native';
+import { NavigationProp, useNavigation } from '@react-navigation/native';
+import { RootStackParamList } from '../navigation/AppNavigator';
+import { Stream } from '../types/metadata';
+import { logger } from '../utils/logger';
+
+interface UseStreamNavigationProps {
+ metadata: {
+ name?: string;
+ year?: number;
+ } | null;
+ currentEpisode?: {
+ name?: string;
+ season_number?: number;
+ episode_number?: number;
+ } | null;
+ id: string;
+ type: string;
+ selectedEpisode?: string;
+ useExternalPlayer?: boolean;
+ preferredPlayer?: 'internal' | 'vlc' | 'outplayer' | 'infuse' | 'vidhub' | 'external';
+}
+
+export const useStreamNavigation = ({
+ metadata,
+ currentEpisode,
+ id,
+ type,
+ selectedEpisode,
+ useExternalPlayer,
+ preferredPlayer
+}: UseStreamNavigationProps) => {
+ const navigation = useNavigation>();
+
+ const navigateToPlayer = useCallback((stream: Stream) => {
+ navigation.navigate('Player', {
+ uri: stream.url,
+ title: metadata?.name || '',
+ episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
+ season: type === 'series' ? currentEpisode?.season_number : undefined,
+ episode: type === 'series' ? currentEpisode?.episode_number : undefined,
+ quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
+ year: metadata?.year,
+ streamProvider: stream.name,
+ id,
+ type,
+ episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined
+ });
+ }, [metadata, type, currentEpisode, navigation, id, selectedEpisode]);
+
+ const handleStreamPress = useCallback(async (stream: Stream) => {
+ try {
+ if (stream.url) {
+ logger.log('handleStreamPress called with stream:', {
+ url: stream.url,
+ behaviorHints: stream.behaviorHints,
+ useExternalPlayer,
+ preferredPlayer
+ });
+
+ // For iOS, try to open with the preferred external player
+ if (Platform.OS === 'ios' && preferredPlayer !== 'internal') {
+ try {
+ // Format the URL for the selected player
+ const streamUrl = encodeURIComponent(stream.url);
+ let externalPlayerUrls: string[] = [];
+
+ // Configure URL formats based on the selected player
+ switch (preferredPlayer) {
+ case 'vlc':
+ externalPlayerUrls = [
+ `vlc://${stream.url}`,
+ `vlc-x-callback://x-callback-url/stream?url=${streamUrl}`,
+ `vlc://${streamUrl}`
+ ];
+ break;
+
+ case 'outplayer':
+ externalPlayerUrls = [
+ `outplayer://${stream.url}`,
+ `outplayer://${streamUrl}`,
+ `outplayer://play?url=${streamUrl}`,
+ `outplayer://stream?url=${streamUrl}`,
+ `outplayer://play/browser?url=${streamUrl}`
+ ];
+ break;
+
+ case 'infuse':
+ externalPlayerUrls = [
+ `infuse://x-callback-url/play?url=${streamUrl}`,
+ `infuse://play?url=${streamUrl}`,
+ `infuse://${streamUrl}`
+ ];
+ break;
+
+ case 'vidhub':
+ externalPlayerUrls = [
+ `vidhub://play?url=${streamUrl}`,
+ `vidhub://${streamUrl}`
+ ];
+ break;
+
+ default:
+ // If no matching player or the setting is somehow invalid, use internal player
+ navigateToPlayer(stream);
+ return;
+ }
+
+ console.log(`Attempting to open stream in ${preferredPlayer}`);
+
+ // Try each URL format in sequence
+ const tryNextUrl = (index: number) => {
+ if (index >= externalPlayerUrls.length) {
+ console.log(`All ${preferredPlayer} formats failed, falling back to direct URL`);
+ // Try direct URL as last resort
+ Linking.openURL(stream.url)
+ .then(() => console.log('Opened with direct URL'))
+ .catch(() => {
+ console.log('Direct URL failed, falling back to built-in player');
+ navigateToPlayer(stream);
+ });
+ return;
+ }
+
+ const url = externalPlayerUrls[index];
+ console.log(`Trying ${preferredPlayer} URL format ${index + 1}: ${url}`);
+
+ Linking.openURL(url)
+ .then(() => console.log(`Successfully opened stream with ${preferredPlayer} format ${index + 1}`))
+ .catch(err => {
+ console.log(`Format ${index + 1} failed: ${err.message}`, err);
+ tryNextUrl(index + 1);
+ });
+ };
+
+ // Start with the first URL format
+ tryNextUrl(0);
+
+ } catch (error) {
+ console.error(`Error with ${preferredPlayer}:`, error);
+ // Fallback to the built-in player
+ navigateToPlayer(stream);
+ }
+ }
+ // For Android with external player preference
+ else if (Platform.OS === 'android' && useExternalPlayer) {
+ try {
+ console.log('Opening stream with Android native app chooser');
+
+ // For Android, determine if the URL is a direct http/https URL or a magnet link
+ const isMagnet = stream.url.startsWith('magnet:');
+
+ if (isMagnet) {
+ // For magnet links, open directly which will trigger the torrent app chooser
+ console.log('Opening magnet link directly');
+ Linking.openURL(stream.url)
+ .then(() => console.log('Successfully opened magnet link'))
+ .catch(err => {
+ console.error('Failed to open magnet link:', err);
+ // No good fallback for magnet links
+ navigateToPlayer(stream);
+ });
+ } else {
+ // For direct video URLs, use the S.Browser.ACTION_VIEW approach
+ // This is a more reliable way to force Android to show all video apps
+
+ // Strip query parameters if they exist as they can cause issues with some apps
+ let cleanUrl = stream.url;
+ if (cleanUrl.includes('?')) {
+ cleanUrl = cleanUrl.split('?')[0];
+ }
+
+ // Create an Android intent URL that forces the chooser
+ // Set component=null to ensure chooser is shown
+ // Set action=android.intent.action.VIEW to open the content
+ const intentUrl = `intent:${cleanUrl}#Intent;action=android.intent.action.VIEW;category=android.intent.category.DEFAULT;component=;type=video/*;launchFlags=0x10000000;end`;
+
+ console.log(`Using intent URL: ${intentUrl}`);
+
+ Linking.openURL(intentUrl)
+ .then(() => console.log('Successfully opened with intent URL'))
+ .catch(err => {
+ console.error('Failed to open with intent URL:', err);
+
+ // First fallback: Try direct URL with regular Linking API
+ console.log('Trying plain URL as fallback');
+ Linking.openURL(stream.url)
+ .then(() => console.log('Opened with direct URL'))
+ .catch(directErr => {
+ console.error('Failed to open direct URL:', directErr);
+
+ // Final fallback: Use built-in player
+ console.log('All external player attempts failed, using built-in player');
+ navigateToPlayer(stream);
+ });
+ });
+ }
+ } catch (error) {
+ console.error('Error with external player:', error);
+ // Fallback to the built-in player
+ navigateToPlayer(stream);
+ }
+ }
+ else {
+ // For internal player or if other options failed, use the built-in player
+ navigateToPlayer(stream);
+ }
+ }
+ } catch (error) {
+ console.error('Error in handleStreamPress:', error);
+ // Final fallback: Use built-in player
+ navigateToPlayer(stream);
+ }
+ }, [navigateToPlayer, preferredPlayer, useExternalPlayer]);
+
+ return {
+ handleStreamPress,
+ navigateToPlayer
+ };
+};
\ No newline at end of file
diff --git a/src/hooks/useStreamProviders.ts b/src/hooks/useStreamProviders.ts
new file mode 100644
index 00000000..d9f4f161
--- /dev/null
+++ b/src/hooks/useStreamProviders.ts
@@ -0,0 +1,146 @@
+import { useState, useEffect, useMemo, useCallback } from 'react';
+import { stremioService } from '../services/stremioService';
+import { Stream } from '../types/metadata';
+import { logger } from '../utils/logger';
+
+interface StreamGroups {
+ [addonId: string]: {
+ addonName: string;
+ streams: Stream[];
+ };
+}
+
+export const useStreamProviders = (
+ groupedStreams: StreamGroups,
+ episodeStreams: StreamGroups,
+ type: string,
+ loadingStreams: boolean,
+ loadingEpisodeStreams: boolean
+) => {
+ const [selectedProvider, setSelectedProvider] = useState('all');
+ const [availableProviders, setAvailableProviders] = useState>(new Set());
+ const [loadingProviders, setLoadingProviders] = useState<{[key: string]: boolean}>({});
+ const [providerStatus, setProviderStatus] = useState<{
+ [key: string]: {
+ loading: boolean;
+ success: boolean;
+ error: boolean;
+ message: string;
+ timeStarted: number;
+ timeCompleted: number;
+ }
+ }>({});
+ const [providerLoadTimes, setProviderLoadTimes] = useState<{[key: string]: number}>({});
+ const [loadStartTime, setLoadStartTime] = useState(0);
+
+ // Update available providers when streams change - converted to useEffect
+ useEffect(() => {
+ const streams = type === 'series' ? episodeStreams : groupedStreams;
+ const providers = new Set(Object.keys(streams));
+ setAvailableProviders(providers);
+ }, [type, groupedStreams, episodeStreams]);
+
+ // Start tracking load time when loading begins - converted to useEffect
+ useEffect(() => {
+ if (loadingStreams || loadingEpisodeStreams) {
+ logger.log("⏱️ Stream loading started");
+ const now = Date.now();
+ setLoadStartTime(now);
+ setProviderLoadTimes({});
+
+ // Reset provider status - only for stremio addons
+ setProviderStatus({
+ 'stremio': {
+ loading: true,
+ success: false,
+ error: false,
+ message: 'Loading...',
+ timeStarted: now,
+ timeCompleted: 0
+ }
+ });
+
+ // Also update the simpler loading state - only for stremio
+ setLoadingProviders({
+ 'stremio': true
+ });
+ }
+ }, [loadingStreams, loadingEpisodeStreams]);
+
+ // Generate filter items for the provider selector
+ const filterItems = useMemo(() => {
+ const installedAddons = stremioService.getInstalledAddons();
+ const streams = type === 'series' ? episodeStreams : groupedStreams;
+
+ return [
+ { id: 'all', name: 'All Providers' },
+ ...Array.from(availableProviders)
+ .sort((a, b) => {
+ const indexA = installedAddons.findIndex(addon => addon.id === a);
+ const indexB = installedAddons.findIndex(addon => addon.id === b);
+
+ if (indexA !== -1 && indexB !== -1) return indexA - indexB;
+ if (indexA !== -1) return -1;
+ if (indexB !== -1) return 1;
+ return 0;
+ })
+ .map(provider => {
+ const addonInfo = streams[provider];
+ const installedAddon = installedAddons.find(addon => addon.id === provider);
+
+ let displayName = provider;
+ if (installedAddon) displayName = installedAddon.name;
+ else if (addonInfo?.addonName) displayName = addonInfo.addonName;
+
+ return { id: provider, name: displayName };
+ })
+ ];
+ }, [availableProviders, type, episodeStreams, groupedStreams]);
+
+ // Filter streams to show only selected provider (or all)
+ const filteredSections = useMemo(() => {
+ const streams = type === 'series' ? episodeStreams : groupedStreams;
+ const installedAddons = stremioService.getInstalledAddons();
+
+ return Object.entries(streams)
+ .filter(([addonId]) => {
+ // If "all" is selected, show all providers
+ if (selectedProvider === 'all') {
+ return true;
+ }
+ // Otherwise only show the selected provider
+ return addonId === selectedProvider;
+ })
+ .sort(([addonIdA], [addonIdB]) => {
+ const indexA = installedAddons.findIndex(addon => addon.id === addonIdA);
+ const indexB = installedAddons.findIndex(addon => addon.id === addonIdB);
+
+ if (indexA !== -1 && indexB !== -1) return indexA - indexB;
+ if (indexA !== -1) return -1;
+ if (indexB !== -1) return 1;
+ return 0;
+ })
+ .map(([addonId, { addonName, streams }]) => ({
+ title: addonName,
+ addonId,
+ data: streams
+ }));
+ }, [selectedProvider, type, episodeStreams, groupedStreams]);
+
+ // Handler for changing the selected provider
+ const handleProviderChange = useCallback((provider: string) => {
+ setSelectedProvider(provider);
+ }, []);
+
+ return {
+ selectedProvider,
+ availableProviders,
+ loadingProviders,
+ providerStatus,
+ filterItems,
+ filteredSections,
+ handleProviderChange,
+ setLoadingProviders,
+ setProviderStatus
+ };
+};
\ No newline at end of file
diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx
index 3f0f27b8..56e33caa 100644
--- a/src/screens/StreamsScreen.tsx
+++ b/src/screens/StreamsScreen.tsx
@@ -1,208 +1,53 @@
-import React, { useCallback, useMemo, memo, useState, useEffect } from 'react';
+import React, { useCallback, useMemo, useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
- FlatList,
SectionList,
Platform,
- ImageBackground,
- ScrollView,
StatusBar,
- Alert,
- Dimensions,
- Linking
+ Dimensions
} from 'react-native';
import { useRoute, useNavigation } from '@react-navigation/native';
import { RouteProp } from '@react-navigation/native';
-import { NavigationProp } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
-import { LinearGradient } from 'expo-linear-gradient';
-import { Image } from 'expo-image';
import { RootStackParamList, RootStackNavigationProp } from '../navigation/AppNavigator';
import { useMetadata } from '../hooks/useMetadata';
import { colors } from '../styles/colors';
import { Stream } from '../types/metadata';
-import { tmdbService } from '../services/tmdbService';
-import { stremioService } from '../services/stremioService';
-import { VideoPlayerService } from '../services/videoPlayerService';
import { useSettings } from '../hooks/useSettings';
-import QualityBadge from '../components/metadata/QualityBadge';
import Animated, {
FadeIn,
- FadeInDown,
- SlideInDown,
- withSpring,
withTiming,
useAnimatedStyle,
useSharedValue,
interpolate,
Extrapolate,
- runOnJS,
- cancelAnimation,
- SharedValue
+ cancelAnimation
} from 'react-native-reanimated';
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 HDR_ICON = 'https://uxwing.com/wp-content/themes/uxwing/download/video-photography-multimedia/hdr-icon.png';
-const DOLBY_ICON = 'https://upload.wikimedia.org/wikipedia/en/thumb/3/3f/Dolby_Vision_%28logo%29.svg/512px-Dolby_Vision_%28logo%29.svg.png?20220908042900';
+// Import custom components
+import StreamCard from '../components/streams/StreamCard';
+import ProviderFilter from '../components/streams/ProviderFilter';
+import MovieHero from '../components/streams/MovieHero';
+import EpisodeHero from '../components/streams/EpisodeHero';
-const { width, height } = Dimensions.get('window');
-
-// Extracted Components
-const StreamCard = ({ stream, onPress, index, isLoading, statusMessage }: {
- stream: Stream;
- onPress: () => void;
- index: number;
- isLoading?: boolean;
- statusMessage?: string;
-}) => {
- const quality = stream.title?.match(/(\d+)p/)?.[1] || null;
- 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 displayTitle = stream.name || stream.title || 'Unnamed Stream';
- const displayAddonName = stream.title || '';
-
- return (
-
-
-
-
-
- {displayTitle}
-
- {displayAddonName && displayAddonName !== displayTitle && (
-
- {displayAddonName}
-
- )}
-
-
- {/* Show loading indicator if stream is loading */}
- {isLoading && (
-
-
-
- {statusMessage || "Loading..."}
-
-
- )}
-
-
-
- {quality && quality >= "720" && (
-
- )}
-
- {isDolby && (
-
- )}
-
- {size && (
-
- {size}
-
- )}
-
- {isDebrid && (
-
- DEBRID
-
- )}
-
-
-
-
-
-
-
- );
-};
-
-const QualityTag = React.memo(({ text, color }: { text: string; color: string }) => (
-
- {text}
-
-));
-
-const ProviderFilter = memo(({
- selectedProvider,
- providers,
- onSelect
-}: {
- selectedProvider: string;
- providers: Array<{ id: string; name: string; }>;
- onSelect: (id: string) => void;
-}) => {
- const renderItem = useCallback(({ item }: { item: { id: string; name: string } }) => (
- onSelect(item.id)}
- >
-
- {item.name}
-
-
- ), [selectedProvider, onSelect]);
-
- return (
- item.id}
- horizontal
- showsHorizontalScrollIndicator={false}
- style={styles.filterScroll}
- bounces={true}
- overScrollMode="never"
- decelerationRate="fast"
- initialNumToRender={5}
- maxToRenderPerBatch={3}
- windowSize={3}
- getItemLayout={(data, index) => ({
- length: 100, // Approximate width of each item
- offset: 100 * index,
- index,
- })}
- />
- );
-});
+// Import custom hooks
+import { useStreamNavigation } from '../hooks/useStreamNavigation';
+import { useStreamProviders } from '../hooks/useStreamProviders';
export const StreamsScreen = () => {
const route = useRoute>();
const navigation = useNavigation();
const { id, type, episodeId } = route.params;
const { settings } = useSettings();
-
- // Add timing logs
- const [loadStartTime, setLoadStartTime] = useState(0);
- const [providerLoadTimes, setProviderLoadTimes] = useState<{[key: string]: number}>({});
+ // Track loading initialization to prevent duplicate loads
+ const [initialLoadComplete, setInitialLoadComplete] = useState(false);
+
const {
metadata,
episodes,
@@ -217,57 +62,32 @@ export const StreamsScreen = () => {
groupedEpisodes,
} = useMetadata({ id, type });
- const [selectedProvider, setSelectedProvider] = React.useState('all');
- const [availableProviders, setAvailableProviders] = React.useState>(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
- const [loadingProviders, setLoadingProviders] = useState<{[key: string]: boolean}>({});
-
- // Add state for more detailed provider loading tracking
- const [providerStatus, setProviderStatus] = useState<{
- [key: string]: {
- loading: boolean;
- success: boolean;
- error: boolean;
- message: string;
- timeStarted: number;
- timeCompleted: number;
- }
- }>({});
+ // Use custom hooks
+ const {
+ selectedProvider,
+ filterItems,
+ filteredSections,
+ handleProviderChange,
+ loadingProviders,
+ providerStatus,
+ setLoadingProviders
+ } = useStreamProviders(
+ groupedStreams,
+ episodeStreams,
+ type,
+ loadingStreams,
+ loadingEpisodeStreams
+ );
- // Monitor streams loading start
+ // Load initial streams only once
useEffect(() => {
- if (loadingStreams || loadingEpisodeStreams) {
- logger.log("⏱️ Stream loading started");
- const now = Date.now();
- setLoadStartTime(now);
- setProviderLoadTimes({});
-
- // Reset provider status - only for stremio addons
- setProviderStatus({
- 'stremio': {
- loading: true,
- success: false,
- error: false,
- message: 'Loading...',
- timeStarted: now,
- timeCompleted: 0
- }
- });
-
- // Also update the simpler loading state - only for stremio
- setLoadingProviders({
- 'stremio': true
- });
- }
- }, [loadingStreams, loadingEpisodeStreams]);
-
- React.useEffect(() => {
+ if (initialLoadComplete) return;
+
if (type === 'series' && episodeId) {
logger.log(`🎬 Loading episode streams for: ${episodeId}`);
setLoadingProviders({
@@ -275,30 +95,31 @@ export const StreamsScreen = () => {
});
setSelectedEpisode(episodeId);
loadEpisodeStreams(episodeId);
+ setInitialLoadComplete(true);
} else if (type === 'movie') {
logger.log(`🎬 Loading movie streams for: ${id}`);
setLoadingProviders({
'stremio': true
});
loadStreams();
+ setInitialLoadComplete(true);
}
- }, [type, episodeId]);
+ }, [
+ initialLoadComplete,
+ type,
+ episodeId,
+ id,
+ loadEpisodeStreams,
+ loadStreams,
+ setSelectedEpisode,
+ setLoadingProviders
+ ]);
- React.useEffect(() => {
- const streams = type === 'series' ? episodeStreams : groupedStreams;
- const providers = new Set(Object.keys(streams));
- setAvailableProviders(providers);
- }, [type, groupedStreams, episodeStreams]);
-
- React.useEffect(() => {
+ // Animation effects
+ useEffect(() => {
// Trigger entrance animations
headerOpacity.value = withTiming(1, { duration: 400 });
- heroScale.value = withSpring(1, {
- damping: 15,
- stiffness: 100,
- mass: 0.9,
- restDisplacementThreshold: 0.01
- });
+ heroScale.value = withTiming(1, { duration: 400 });
filterOpacity.value = withTiming(1, { duration: 500 });
return () => {
@@ -307,7 +128,29 @@ export const StreamsScreen = () => {
cancelAnimation(heroScale);
cancelAnimation(filterOpacity);
};
- }, []);
+ }, [headerOpacity, heroScale, filterOpacity]);
+
+ const currentEpisode = useMemo(() => {
+ if (!selectedEpisode) return null;
+
+ // Search through all episodes in all seasons
+ const allEpisodes = Object.values(groupedEpisodes).flat();
+ return allEpisodes.find(ep =>
+ ep.stremioId === selectedEpisode ||
+ `${id}:${ep.season_number}:${ep.episode_number}` === selectedEpisode
+ );
+ }, [selectedEpisode, groupedEpisodes, id]);
+
+ // Use navigation hook
+ const { handleStreamPress } = useStreamNavigation({
+ metadata,
+ currentEpisode,
+ id,
+ type,
+ selectedEpisode: selectedEpisode || undefined,
+ useExternalPlayer: settings.useExternalPlayer,
+ preferredPlayer: settings.preferredPlayer as 'internal' | 'vlc' | 'outplayer' | 'infuse' | 'vidhub' | 'external'
+ });
// Memoize handlers
const handleBack = useCallback(() => {
@@ -329,272 +172,6 @@ export const StreamsScreen = () => {
}
}, [navigation, headerOpacity, heroScale, filterOpacity, type, id]);
- const handleProviderChange = useCallback((provider: string) => {
- setSelectedProvider(provider);
- }, []);
-
- const currentEpisode = useMemo(() => {
- if (!selectedEpisode) return null;
-
- // Search through all episodes in all seasons
- const allEpisodes = Object.values(groupedEpisodes).flat();
- return allEpisodes.find(ep =>
- ep.stremioId === selectedEpisode ||
- `${id}:${ep.season_number}:${ep.episode_number}` === selectedEpisode
- );
- }, [selectedEpisode, groupedEpisodes, id]);
-
- const navigateToPlayer = useCallback((stream: Stream) => {
- navigation.navigate('Player', {
- uri: stream.url,
- title: metadata?.name || '',
- episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
- season: type === 'series' ? currentEpisode?.season_number : undefined,
- episode: type === 'series' ? currentEpisode?.episode_number : undefined,
- quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
- year: metadata?.year,
- streamProvider: stream.name,
- id,
- type,
- episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined
- });
- }, [metadata, type, currentEpisode, navigation, id, selectedEpisode]);
-
- // Update handleStreamPress
- const handleStreamPress = useCallback(async (stream: Stream) => {
- try {
- if (stream.url) {
- logger.log('handleStreamPress called with stream:', {
- url: stream.url,
- behaviorHints: stream.behaviorHints,
- useExternalPlayer: settings.useExternalPlayer,
- preferredPlayer: settings.preferredPlayer
- });
-
- // For iOS, try to open with the preferred external player
- if (Platform.OS === 'ios' && settings.preferredPlayer !== 'internal') {
- try {
- // Format the URL for the selected player
- const streamUrl = encodeURIComponent(stream.url);
- let externalPlayerUrls: string[] = [];
-
- // Configure URL formats based on the selected player
- switch (settings.preferredPlayer) {
- case 'vlc':
- externalPlayerUrls = [
- `vlc://${stream.url}`,
- `vlc-x-callback://x-callback-url/stream?url=${streamUrl}`,
- `vlc://${streamUrl}`
- ];
- break;
-
- case 'outplayer':
- externalPlayerUrls = [
- `outplayer://${stream.url}`,
- `outplayer://${streamUrl}`,
- `outplayer://play?url=${streamUrl}`,
- `outplayer://stream?url=${streamUrl}`,
- `outplayer://play/browser?url=${streamUrl}`
- ];
- break;
-
- case 'infuse':
- externalPlayerUrls = [
- `infuse://x-callback-url/play?url=${streamUrl}`,
- `infuse://play?url=${streamUrl}`,
- `infuse://${streamUrl}`
- ];
- break;
-
- case 'vidhub':
- externalPlayerUrls = [
- `vidhub://play?url=${streamUrl}`,
- `vidhub://${streamUrl}`
- ];
- break;
-
- default:
- // If no matching player or the setting is somehow invalid, use internal player
- navigateToPlayer(stream);
- return;
- }
-
- console.log(`Attempting to open stream in ${settings.preferredPlayer}`);
-
- // Try each URL format in sequence
- const tryNextUrl = (index: number) => {
- if (index >= externalPlayerUrls.length) {
- console.log(`All ${settings.preferredPlayer} formats failed, falling back to direct URL`);
- // Try direct URL as last resort
- Linking.openURL(stream.url)
- .then(() => console.log('Opened with direct URL'))
- .catch(() => {
- console.log('Direct URL failed, falling back to built-in player');
- navigateToPlayer(stream);
- });
- return;
- }
-
- const url = externalPlayerUrls[index];
- console.log(`Trying ${settings.preferredPlayer} URL format ${index + 1}: ${url}`);
-
- Linking.openURL(url)
- .then(() => console.log(`Successfully opened stream with ${settings.preferredPlayer} format ${index + 1}`))
- .catch(err => {
- console.log(`Format ${index + 1} failed: ${err.message}`, err);
- tryNextUrl(index + 1);
- });
- };
-
- // Start with the first URL format
- tryNextUrl(0);
-
- } catch (error) {
- console.error(`Error with ${settings.preferredPlayer}:`, error);
- // Fallback to the built-in player
- navigateToPlayer(stream);
- }
- }
- // For Android with external player preference
- else if (Platform.OS === 'android' && settings.useExternalPlayer) {
- try {
- console.log('Opening stream with Android native app chooser');
-
- // For Android, determine if the URL is a direct http/https URL or a magnet link
- const isMagnet = stream.url.startsWith('magnet:');
-
- if (isMagnet) {
- // For magnet links, open directly which will trigger the torrent app chooser
- console.log('Opening magnet link directly');
- Linking.openURL(stream.url)
- .then(() => console.log('Successfully opened magnet link'))
- .catch(err => {
- console.error('Failed to open magnet link:', err);
- // No good fallback for magnet links
- navigateToPlayer(stream);
- });
- } else {
- // For direct video URLs, use the S.Browser.ACTION_VIEW approach
- // This is a more reliable way to force Android to show all video apps
-
- // Strip query parameters if they exist as they can cause issues with some apps
- let cleanUrl = stream.url;
- if (cleanUrl.includes('?')) {
- cleanUrl = cleanUrl.split('?')[0];
- }
-
- // Create an Android intent URL that forces the chooser
- // Set component=null to ensure chooser is shown
- // Set action=android.intent.action.VIEW to open the content
- const intentUrl = `intent:${cleanUrl}#Intent;action=android.intent.action.VIEW;category=android.intent.category.DEFAULT;component=;type=video/*;launchFlags=0x10000000;end`;
-
- console.log(`Using intent URL: ${intentUrl}`);
-
- Linking.openURL(intentUrl)
- .then(() => console.log('Successfully opened with intent URL'))
- .catch(err => {
- console.error('Failed to open with intent URL:', err);
-
- // First fallback: Try direct URL with regular Linking API
- console.log('Trying plain URL as fallback');
- Linking.openURL(stream.url)
- .then(() => console.log('Opened with direct URL'))
- .catch(directErr => {
- console.error('Failed to open direct URL:', directErr);
-
- // Final fallback: Use built-in player
- console.log('All external player attempts failed, using built-in player');
- navigateToPlayer(stream);
- });
- });
- }
- } catch (error) {
- console.error('Error with external player:', error);
- // Fallback to the built-in player
- navigateToPlayer(stream);
- }
- }
- else {
- // For internal player or if other options failed, use the built-in player
- navigateToPlayer(stream);
- }
- }
- } catch (error) {
- console.error('Error in handleStreamPress:', error);
- // Final fallback: Use built-in player
- navigateToPlayer(stream);
- }
- }, [settings.preferredPlayer, settings.useExternalPlayer, navigateToPlayer]);
-
- const filterItems = useMemo(() => {
- const installedAddons = stremioService.getInstalledAddons();
- const streams = type === 'series' ? episodeStreams : groupedStreams;
-
- return [
- { id: 'all', name: 'All Providers' },
- ...Array.from(availableProviders)
- .sort((a, b) => {
- const indexA = installedAddons.findIndex(addon => addon.id === a);
- const indexB = installedAddons.findIndex(addon => addon.id === b);
-
- if (indexA !== -1 && indexB !== -1) return indexA - indexB;
- if (indexA !== -1) return -1;
- if (indexB !== -1) return 1;
- return 0;
- })
- .map(provider => {
- const addonInfo = streams[provider];
- const installedAddon = installedAddons.find(addon => addon.id === provider);
-
- let displayName = provider;
- if (installedAddon) displayName = installedAddon.name;
- else if (addonInfo?.addonName) displayName = addonInfo.addonName;
-
- return { id: provider, name: displayName };
- })
- ];
- }, [availableProviders, type, episodeStreams, groupedStreams]);
-
- const sections = useMemo(() => {
- const streams = type === 'series' ? episodeStreams : groupedStreams;
- const installedAddons = stremioService.getInstalledAddons();
-
- // Filter streams by selected provider - only if not "all"
- const filteredEntries = Object.entries(streams)
- .filter(([addonId]) => {
- // If "all" is selected, show all providers
- if (selectedProvider === 'all') {
- return true;
- }
- // Otherwise only show the selected provider
- return addonId === selectedProvider;
- })
- .sort(([addonIdA], [addonIdB]) => {
- const indexA = installedAddons.findIndex(addon => addon.id === addonIdA);
- const indexB = installedAddons.findIndex(addon => addon.id === addonIdB);
-
- if (indexA !== -1 && indexB !== -1) return indexA - indexB;
- if (indexA !== -1) return -1;
- if (indexB !== -1) return 1;
- return 0;
- })
- .map(([addonId, { addonName, streams }]) => ({
- title: addonName,
- addonId,
- data: streams
- }));
-
- return filteredEntries;
- }, [selectedProvider, type, episodeStreams, groupedStreams]);
-
- const episodeImage = useMemo(() => {
- if (!currentEpisode) return null;
- if (currentEpisode.still_path) {
- return tmdbService.getImageUrl(currentEpisode.still_path, 'original');
- }
- return metadata?.poster || null;
- }, [currentEpisode, metadata]);
-
const isLoading = type === 'series' ? loadingEpisodeStreams : loadingStreams;
const streams = type === 'series' ? episodeStreams : groupedStreams;
@@ -649,6 +226,7 @@ export const StreamsScreen = () => {
barStyle="light-content"
/>
+ {/* Back Button */}
{
+ {/* Movie Hero */}
{type === 'movie' && metadata && (
-
-
-
-
- {metadata.logo ? (
-
- ) : (
-
- {metadata.name}
-
- )}
-
-
-
-
+
)}
+ {/* Episode Hero */}
{type === 'series' && currentEpisode && (
-
-
-
-
-
-
-
-
- {currentEpisode.episodeString}
-
-
- {currentEpisode.name}
-
- {currentEpisode.overview && (
-
- {currentEpisode.overview}
-
- )}
-
-
- {tmdbService.formatAirDate(currentEpisode.air_date)}
-
- {currentEpisode.vote_average > 0 && (
-
-
-
- {currentEpisode.vote_average.toFixed(1)}
-
-
- )}
- {currentEpisode.runtime && (
-
-
-
- {currentEpisode.runtime >= 60
- ? `${Math.floor(currentEpisode.runtime / 60)}h ${currentEpisode.runtime % 60}m`
- : `${currentEpisode.runtime}m`}
-
-
- )}
-
-
-
-
-
-
-
-
+
)}
+ {/* Stream List */}
+ {/* Provider Filter */}
{Object.keys(streams).length > 0 && (
{
)}
+ {/* Loading or Empty State */}
{isLoading && Object.keys(streams).length === 0 ? (
{
) : (
item.url || `${item.name}-${item.title}`}
renderItem={renderItem}
renderSectionHeader={renderSectionHeader}
@@ -880,30 +361,6 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
paddingBottom: 12,
},
- filterScroll: {
- flexGrow: 0,
- },
- filterChip: {
- backgroundColor: colors.transparentLight,
- paddingHorizontal: 16,
- paddingVertical: 8,
- borderRadius: 20,
- marginRight: 8,
- borderWidth: 1,
- borderColor: colors.transparent,
- },
- filterChipSelected: {
- backgroundColor: colors.transparentLight,
- borderColor: colors.primary,
- },
- filterChipText: {
- color: colors.text,
- fontWeight: '500',
- },
- filterChipTextSelected: {
- color: colors.primary,
- fontWeight: 'bold',
- },
streamsContent: {
flex: 1,
width: '100%',
@@ -914,10 +371,6 @@ const styles = StyleSheet.create({
paddingBottom: 16,
width: '100%',
},
- streamGroup: {
- marginBottom: 24,
- width: '100%',
- },
streamGroupTitle: {
color: colors.text,
fontSize: 16,
@@ -926,123 +379,6 @@ const styles = StyleSheet.create({
marginTop: 0,
backgroundColor: 'transparent',
},
- streamCard: {
- flexDirection: 'row',
- alignItems: 'flex-start',
- padding: 12,
- borderRadius: 12,
- marginBottom: 8,
- minHeight: 70,
- backgroundColor: colors.elevation1,
- borderWidth: 1,
- borderColor: 'rgba(255,255,255,0.05)',
- width: '100%',
- zIndex: 1,
- },
- streamCardLoading: {
- opacity: 0.7,
- },
- streamDetails: {
- flex: 1,
- },
- streamNameRow: {
- flexDirection: 'row',
- alignItems: 'flex-start',
- justifyContent: 'space-between',
- width: '100%',
- flexWrap: 'wrap',
- gap: 8
- },
- streamTitleContainer: {
- flex: 1,
- },
- streamName: {
- fontSize: 14,
- fontWeight: '600',
- marginBottom: 2,
- lineHeight: 20,
- color: colors.highEmphasis,
- },
- streamAddonName: {
- fontSize: 13,
- lineHeight: 18,
- color: colors.mediumEmphasis,
- marginBottom: 6,
- },
- streamMetaRow: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- gap: 4,
- marginBottom: 6,
- alignItems: 'center',
- },
- chip: {
- paddingHorizontal: 8,
- paddingVertical: 2,
- borderRadius: 4,
- marginRight: 4,
- marginBottom: 4,
- },
- chipText: {
- color: colors.highEmphasis,
- fontSize: 12,
- fontWeight: '600',
- },
- progressContainer: {
- height: 20,
- backgroundColor: colors.transparentLight,
- borderRadius: 10,
- overflow: 'hidden',
- marginBottom: 6,
- },
- progressBar: {
- height: '100%',
- backgroundColor: colors.primary,
- },
- progressText: {
- color: colors.highEmphasis,
- fontSize: 12,
- fontWeight: '600',
- marginLeft: 8,
- },
- streamAction: {
- width: 40,
- height: 40,
- borderRadius: 20,
- backgroundColor: colors.elevation2,
- justifyContent: 'center',
- alignItems: 'center',
- },
- skeletonCard: {
- opacity: 0.7,
- },
- skeletonTitle: {
- height: 24,
- width: '40%',
- backgroundColor: colors.transparentLight,
- borderRadius: 4,
- marginBottom: 16,
- },
- skeletonIcon: {
- width: 24,
- height: 24,
- borderRadius: 12,
- backgroundColor: colors.transparentLight,
- marginRight: 12,
- },
- skeletonText: {
- height: 16,
- borderRadius: 4,
- marginBottom: 8,
- backgroundColor: colors.transparentLight,
- },
- skeletonTag: {
- width: 60,
- height: 20,
- borderRadius: 4,
- marginRight: 8,
- backgroundColor: colors.transparentLight,
- },
noStreams: {
flex: 1,
justifyContent: 'center',
@@ -1054,90 +390,6 @@ const styles = StyleSheet.create({
fontSize: 16,
marginTop: 16,
},
- streamsHeroContainer: {
- width: '100%',
- height: 300,
- marginBottom: 0,
- position: 'relative',
- backgroundColor: colors.black,
- pointerEvents: 'box-none',
- },
- streamsHeroBackground: {
- width: '100%',
- height: '100%',
- backgroundColor: colors.black,
- },
- streamsHeroGradient: {
- flex: 1,
- justifyContent: 'flex-end',
- padding: 16,
- paddingBottom: 0,
- },
- streamsHeroContent: {
- width: '100%',
- },
- streamsHeroInfo: {
- width: '100%',
- },
- streamsHeroEpisodeNumber: {
- color: colors.primary,
- fontSize: 14,
- fontWeight: 'bold',
- marginBottom: 2,
- textShadowColor: 'rgba(0,0,0,0.75)',
- textShadowOffset: { width: 0, height: 1 },
- textShadowRadius: 2,
- },
- streamsHeroTitle: {
- color: colors.highEmphasis,
- fontSize: 24,
- fontWeight: 'bold',
- marginBottom: 4,
- textShadowColor: 'rgba(0,0,0,0.75)',
- textShadowOffset: { width: 0, height: 1 },
- textShadowRadius: 3,
- },
- streamsHeroOverview: {
- color: colors.mediumEmphasis,
- fontSize: 14,
- lineHeight: 20,
- marginBottom: 2,
- textShadowColor: 'rgba(0,0,0,0.75)',
- textShadowOffset: { width: 0, height: 1 },
- textShadowRadius: 2,
- },
- streamsHeroMeta: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 12,
- marginTop: 0,
- },
- streamsHeroReleased: {
- color: colors.mediumEmphasis,
- fontSize: 14,
- textShadowColor: 'rgba(0,0,0,0.75)',
- textShadowOffset: { width: 0, height: 1 },
- textShadowRadius: 2,
- },
- streamsHeroRating: {
- flexDirection: 'row',
- alignItems: 'center',
- backgroundColor: 'rgba(0,0,0,0.7)',
- paddingHorizontal: 6,
- paddingVertical: 3,
- borderRadius: 4,
- marginTop: 0,
- },
- tmdbLogo: {
- width: 20,
- height: 14,
- },
- streamsHeroRatingText: {
- color: '#01b4e4',
- fontSize: 13,
- fontWeight: '700',
- marginLeft: 4,
- },
loadingContainer: {
alignItems: 'center',
paddingVertical: 24,
@@ -1148,29 +400,6 @@ const styles = StyleSheet.create({
marginLeft: 4,
fontWeight: '500',
},
- downloadingIndicator: {
- flexDirection: 'row',
- alignItems: 'center',
- backgroundColor: colors.transparentLight,
- paddingHorizontal: 8,
- paddingVertical: 2,
- borderRadius: 12,
- marginLeft: 8,
- },
- downloadingText: {
- color: colors.primary,
- fontSize: 12,
- marginLeft: 4,
- fontWeight: '500',
- },
- loadingIndicator: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: 8,
- paddingVertical: 2,
- borderRadius: 12,
- marginLeft: 8,
- },
footerLoading: {
flexDirection: 'row',
alignItems: 'center',
@@ -1183,56 +412,6 @@ const styles = StyleSheet.create({
marginLeft: 8,
fontWeight: '500',
},
- movieTitleContainer: {
- width: '100%',
- height: 180,
- backgroundColor: colors.black,
- pointerEvents: 'box-none',
- },
- movieTitleBackground: {
- width: '100%',
- height: '100%',
- backgroundColor: colors.black,
- },
- movieTitleGradient: {
- flex: 1,
- justifyContent: 'center',
- padding: 16,
- },
- movieTitleContent: {
- width: '100%',
- alignItems: 'center',
- marginTop: Platform.OS === 'android' ? 35 : 45,
- },
- movieLogo: {
- width: width * 0.6,
- height: 70,
- marginBottom: 8,
- },
- movieTitle: {
- color: colors.highEmphasis,
- fontSize: 28,
- fontWeight: '900',
- textAlign: 'center',
- textShadowColor: 'rgba(0,0,0,0.75)',
- textShadowOffset: { width: 0, height: 2 },
- textShadowRadius: 4,
- letterSpacing: -0.5,
- },
- streamsHeroRuntime: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 4,
- backgroundColor: 'rgba(0,0,0,0.5)',
- paddingHorizontal: 8,
- paddingVertical: 4,
- borderRadius: 6,
- },
- streamsHeroRuntimeText: {
- color: colors.mediumEmphasis,
- fontSize: 13,
- fontWeight: '600',
- },
});
-export default memo(StreamsScreen);
\ No newline at end of file
+export default React.memo(StreamsScreen);
\ No newline at end of file