diff --git a/src/components/streams/EpisodeHero.tsx b/src/components/streams/EpisodeHero.tsx
deleted file mode 100644
index 92d8d12f..00000000
--- a/src/components/streams/EpisodeHero.tsx
+++ /dev/null
@@ -1,223 +0,0 @@
-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
deleted file mode 100644
index d05c6a83..00000000
--- a/src/components/streams/MovieHero.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-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
deleted file mode 100644
index 800051f9..00000000
--- a/src/components/streams/ProviderFilter.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-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
deleted file mode 100644
index cb69824d..00000000
--- a/src/components/streams/StreamCard.tsx
+++ /dev/null
@@ -1,181 +0,0 @@
-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
deleted file mode 100644
index b3925830..00000000
--- a/src/hooks/useStreamNavigation.ts
+++ /dev/null
@@ -1,221 +0,0 @@
-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
deleted file mode 100644
index d9f4f161..00000000
--- a/src/hooks/useStreamProviders.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-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 56e33caa..3f0f27b8 100644
--- a/src/screens/StreamsScreen.tsx
+++ b/src/screens/StreamsScreen.tsx
@@ -1,53 +1,208 @@
-import React, { useCallback, useMemo, useState, useEffect } from 'react';
+import React, { useCallback, useMemo, memo, useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
+ FlatList,
SectionList,
Platform,
+ ImageBackground,
+ ScrollView,
StatusBar,
- Dimensions
+ Alert,
+ Dimensions,
+ Linking
} 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,
- cancelAnimation
+ runOnJS,
+ cancelAnimation,
+ SharedValue
} from 'react-native-reanimated';
import { logger } from '../utils/logger';
-// 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 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 hooks
-import { useStreamNavigation } from '../hooks/useStreamNavigation';
-import { useStreamProviders } from '../hooks/useStreamProviders';
+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,
+ })}
+ />
+ );
+});
export const StreamsScreen = () => {
const route = useRoute>();
const navigation = useNavigation();
const { id, type, episodeId } = route.params;
const { settings } = useSettings();
-
- // Track loading initialization to prevent duplicate loads
- const [initialLoadComplete, setInitialLoadComplete] = useState(false);
+ // Add timing logs
+ const [loadStartTime, setLoadStartTime] = useState(0);
+ const [providerLoadTimes, setProviderLoadTimes] = useState<{[key: string]: number}>({});
+
const {
metadata,
episodes,
@@ -62,32 +217,57 @@ 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);
- // Use custom hooks
- const {
- selectedProvider,
- filterItems,
- filteredSections,
- handleProviderChange,
- loadingProviders,
- providerStatus,
- setLoadingProviders
- } = useStreamProviders(
- groupedStreams,
- episodeStreams,
- type,
- loadingStreams,
- loadingEpisodeStreams
- );
+ // 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;
+ }
+ }>({});
- // Load initial streams only once
+ // Monitor streams loading start
useEffect(() => {
- if (initialLoadComplete) return;
-
+ 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 (type === 'series' && episodeId) {
logger.log(`🎬 Loading episode streams for: ${episodeId}`);
setLoadingProviders({
@@ -95,31 +275,30 @@ 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);
}
- }, [
- initialLoadComplete,
- type,
- episodeId,
- id,
- loadEpisodeStreams,
- loadStreams,
- setSelectedEpisode,
- setLoadingProviders
- ]);
+ }, [type, episodeId]);
- // Animation effects
- useEffect(() => {
+ React.useEffect(() => {
+ const streams = type === 'series' ? episodeStreams : groupedStreams;
+ const providers = new Set(Object.keys(streams));
+ setAvailableProviders(providers);
+ }, [type, groupedStreams, episodeStreams]);
+
+ React.useEffect(() => {
// Trigger entrance animations
headerOpacity.value = withTiming(1, { duration: 400 });
- heroScale.value = withTiming(1, { duration: 400 });
+ heroScale.value = withSpring(1, {
+ damping: 15,
+ stiffness: 100,
+ mass: 0.9,
+ restDisplacementThreshold: 0.01
+ });
filterOpacity.value = withTiming(1, { duration: 500 });
return () => {
@@ -128,29 +307,7 @@ 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(() => {
@@ -172,6 +329,272 @@ 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;
@@ -226,7 +649,6 @@ 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}
@@ -361,6 +880,30 @@ 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%',
@@ -371,6 +914,10 @@ const styles = StyleSheet.create({
paddingBottom: 16,
width: '100%',
},
+ streamGroup: {
+ marginBottom: 24,
+ width: '100%',
+ },
streamGroupTitle: {
color: colors.text,
fontSize: 16,
@@ -379,6 +926,123 @@ 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',
@@ -390,6 +1054,90 @@ 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,
@@ -400,6 +1148,29 @@ 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',
@@ -412,6 +1183,56 @@ 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 React.memo(StreamsScreen);
\ No newline at end of file
+export default memo(StreamsScreen);
\ No newline at end of file