refactor streamscreen

This commit is contained in:
tapframe 2025-12-27 19:12:25 +05:30
parent 7885df341e
commit aed4fed56f
13 changed files with 2975 additions and 2872 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,215 @@
import React, { memo } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, StatusBar, Platform } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialIcons } from '@expo/vector-icons';
import { PaperProvider } from 'react-native-paper';
import TabletStreamsLayout from '../../components/TabletStreamsLayout';
import CustomAlert from '../../components/CustomAlert';
import { MobileStreamsLayout } from './components';
import { useStreamsScreen } from './useStreamsScreen';
import { createStyles } from './styles';
import { StreamSection } from './types';
export const StreamsScreen = () => {
const insets = useSafeAreaInsets();
const {
// Route params
id,
type,
// Theme
currentTheme,
colors,
settings,
// Navigation
navigation,
handleBack,
// Tablet
isTablet,
// Alert
alertVisible,
alertTitle,
alertMessage,
alertActions,
openAlert,
closeAlert,
// Metadata
metadata,
imdbId,
bannerImage,
currentEpisode,
// Streams
streams,
groupedStreams,
episodeStreams,
sections,
filterItems,
selectedProvider,
handleProviderChange,
handleStreamPress,
// Loading states
loadingStreams,
loadingEpisodeStreams,
loadingProviders,
streamsEmpty,
showInitialLoading,
showStillFetching,
showNoSourcesError,
hasStremioStreamProviders,
// Autoplay
isAutoplayWaiting,
autoplayTriggered,
// Scrapers
activeFetchingScrapers,
scraperLogos,
// Movie
movieLogoError,
setMovieLogoError,
// Episode
episodeImage,
effectiveEpisodeVote,
effectiveEpisodeRuntime,
hasIMDbRating,
selectedEpisode,
// Backdrop
mobileBackdropSource,
gradientColors,
} = useStreamsScreen();
const styles = React.useMemo(() => createStyles(colors), [colors]);
return (
<PaperProvider>
<View style={styles.container}>
<StatusBar translucent backgroundColor="transparent" barStyle="light-content" />
{/* Back Button (Android only) */}
{Platform.OS !== 'ios' && (
<View style={[styles.backButtonContainer, isTablet && styles.backButtonContainerTablet]}>
<TouchableOpacity
style={[styles.backButton, Platform.OS === 'android' ? { paddingTop: 45 } : null]}
onPress={handleBack}
activeOpacity={0.7}
>
<MaterialIcons name="arrow-back" size={24} color={colors.white} />
<Text style={styles.backButtonText}>
{metadata?.videos && metadata.videos.length > 1 && selectedEpisode
? 'Back to Episodes'
: 'Back to Info'}
</Text>
</TouchableOpacity>
</View>
)}
{/* Tablet Layout */}
{isTablet ? (
<TabletStreamsLayout
episodeImage={episodeImage}
bannerImage={bannerImage}
metadata={metadata}
type={type}
currentEpisode={currentEpisode}
movieLogoError={movieLogoError}
setMovieLogoError={setMovieLogoError}
streamsEmpty={streamsEmpty}
selectedProvider={selectedProvider}
filterItems={filterItems}
handleProviderChange={handleProviderChange}
activeFetchingScrapers={activeFetchingScrapers}
isAutoplayWaiting={isAutoplayWaiting}
autoplayTriggered={autoplayTriggered}
showNoSourcesError={showNoSourcesError}
showInitialLoading={showInitialLoading}
showStillFetching={showStillFetching}
sections={sections}
renderSectionHeader={({ section }: { section: { title: string; addonId: string; isEmptyDueToQualityFilter?: boolean } }) => (
<View style={styles.sectionHeaderContainer}>
<View style={styles.sectionHeaderContent}>
<Text style={styles.streamGroupTitle}>{section.title}</Text>
</View>
</View>
)}
handleStreamPress={handleStreamPress}
openAlert={openAlert}
settings={settings}
currentTheme={currentTheme}
colors={colors}
navigation={navigation}
insets={insets}
streams={streams}
scraperLogos={scraperLogos}
id={id}
imdbId={imdbId || undefined}
loadingStreams={loadingStreams}
loadingEpisodeStreams={loadingEpisodeStreams}
hasStremioStreamProviders={hasStremioStreamProviders}
/>
) : (
/* Mobile Layout */
<MobileStreamsLayout
navigation={navigation}
currentTheme={currentTheme}
colors={colors}
settings={settings}
type={type}
metadata={metadata}
currentEpisode={currentEpisode}
selectedEpisode={selectedEpisode || undefined}
movieLogoError={movieLogoError}
setMovieLogoError={setMovieLogoError}
episodeImage={episodeImage}
effectiveEpisodeVote={effectiveEpisodeVote}
effectiveEpisodeRuntime={effectiveEpisodeRuntime}
hasIMDbRating={hasIMDbRating}
gradientColors={gradientColors}
mobileBackdropSource={mobileBackdropSource}
sections={sections}
streams={streams}
filterItems={filterItems}
selectedProvider={selectedProvider}
handleProviderChange={handleProviderChange}
handleStreamPress={handleStreamPress}
loadingProviders={loadingProviders}
loadingStreams={loadingStreams}
loadingEpisodeStreams={loadingEpisodeStreams}
hasStremioStreamProviders={hasStremioStreamProviders}
streamsEmpty={streamsEmpty}
showInitialLoading={showInitialLoading}
showStillFetching={showStillFetching}
showNoSourcesError={showNoSourcesError}
isAutoplayWaiting={isAutoplayWaiting}
autoplayTriggered={autoplayTriggered}
activeFetchingScrapers={activeFetchingScrapers}
scraperLogos={scraperLogos}
openAlert={openAlert}
id={id}
imdbId={imdbId || undefined}
/>
)}
{/* Custom Alert Dialog */}
<CustomAlert
visible={alertVisible}
title={alertTitle}
message={alertMessage}
actions={alertActions}
onClose={closeAlert}
/>
</View>
</PaperProvider>
);
};
export default memo(StreamsScreen);

View file

@ -0,0 +1,226 @@
import React, { memo } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons } from '@expo/vector-icons';
import AnimatedImage from '../../../components/AnimatedImage';
import AnimatedText from '../../../components/AnimatedText';
import AnimatedView from '../../../components/AnimatedView';
import { tmdbService } from '../../../services/tmdbService';
import { TMDB_LOGO, IMDb_LOGO } from '../constants';
interface EpisodeHeroProps {
episodeImage: string | null;
currentEpisode: {
episodeString: string;
name: string;
overview?: string;
air_date?: string;
season_number: number;
episode_number: number;
};
effectiveEpisodeVote: number;
effectiveEpisodeRuntime?: number;
hasIMDbRating: boolean;
gradientColors: [string, string, string, string, string];
colors: any;
enableStreamsBackdrop: boolean;
}
const EpisodeHero = memo(
({
episodeImage,
currentEpisode,
effectiveEpisodeVote,
effectiveEpisodeRuntime,
hasIMDbRating,
gradientColors,
colors,
enableStreamsBackdrop,
}: EpisodeHeroProps) => {
const styles = React.useMemo(() => createStyles(colors), [colors]);
return (
<View
style={[styles.container, !enableStreamsBackdrop && { backgroundColor: colors.darkBackground }]}
>
<View style={StyleSheet.absoluteFill}>
<View style={StyleSheet.absoluteFill}>
<AnimatedImage
source={episodeImage ? { uri: episodeImage } : undefined}
style={styles.background}
contentFit="cover"
/>
<LinearGradient
colors={gradientColors}
locations={[0, 0.4, 0.6, 0.8, 1]}
style={styles.gradient}
>
<View style={styles.content}>
<View style={styles.info}>
<AnimatedText style={styles.episodeNumber} delay={50}>
{currentEpisode.episodeString}
</AnimatedText>
<AnimatedText style={styles.title} numberOfLines={1} delay={100}>
{currentEpisode.name}
</AnimatedText>
{!!currentEpisode.overview && (
<AnimatedView delay={150}>
<Text style={styles.overview} numberOfLines={2}>
{currentEpisode.overview}
</Text>
</AnimatedView>
)}
<AnimatedView style={styles.meta} delay={200}>
<Text style={styles.released}>
{tmdbService.formatAirDate(currentEpisode.air_date || null)}
</Text>
{effectiveEpisodeVote > 0 && (
<View style={styles.rating}>
{hasIMDbRating ? (
<>
<FastImage
source={{ uri: IMDb_LOGO }}
style={styles.imdbLogo}
resizeMode={FastImage.resizeMode.contain}
/>
<Text style={[styles.ratingText, { color: '#F5C518' }]}>
{effectiveEpisodeVote.toFixed(1)}
</Text>
</>
) : (
<>
<FastImage
source={{ uri: TMDB_LOGO }}
style={styles.tmdbLogo}
resizeMode={FastImage.resizeMode.contain}
/>
<Text style={styles.ratingText}>{effectiveEpisodeVote.toFixed(1)}</Text>
</>
)}
</View>
)}
{!!effectiveEpisodeRuntime && (
<View style={styles.runtime}>
<MaterialIcons name="schedule" size={16} color={colors.mediumEmphasis} />
<Text style={styles.runtimeText}>
{effectiveEpisodeRuntime >= 60
? `${Math.floor(effectiveEpisodeRuntime / 60)}h ${effectiveEpisodeRuntime % 60}m`
: `${effectiveEpisodeRuntime}m`}
</Text>
</View>
)}
</AnimatedView>
</View>
</View>
</LinearGradient>
</View>
</View>
</View>
);
}
);
const createStyles = (colors: any) =>
StyleSheet.create({
container: {
width: '100%',
height: 220,
marginBottom: 0,
position: 'relative',
backgroundColor: 'transparent',
pointerEvents: 'box-none',
zIndex: 1,
},
background: {
width: '100%',
height: '100%',
backgroundColor: 'transparent',
},
gradient: {
...StyleSheet.absoluteFillObject,
justifyContent: 'flex-end',
padding: 16,
paddingBottom: 0,
},
content: {
width: '100%',
},
info: {
width: '100%',
},
episodeNumber: {
color: colors.primary,
fontSize: 14,
fontWeight: 'bold',
marginBottom: 2,
textShadowColor: 'rgba(0,0,0,0.75)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
title: {
color: colors.highEmphasis,
fontSize: 24,
fontWeight: 'bold',
marginBottom: 4,
textShadowColor: 'rgba(0,0,0,0.75)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 3,
},
overview: {
color: colors.mediumEmphasis,
fontSize: 14,
lineHeight: 20,
marginBottom: 2,
textShadowColor: 'rgba(0,0,0,0.75)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
meta: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
marginTop: 0,
},
released: {
color: colors.mediumEmphasis,
fontSize: 14,
textShadowColor: 'rgba(0,0,0,0.75)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
rating: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 0,
},
tmdbLogo: {
width: 20,
height: 14,
},
imdbLogo: {
width: 28,
height: 15,
},
ratingText: {
color: colors.highEmphasis,
fontSize: 13,
fontWeight: '700',
marginLeft: 4,
},
runtime: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
runtimeText: {
color: colors.mediumEmphasis,
fontSize: 13,
fontWeight: '600',
},
});
EpisodeHero.displayName = 'EpisodeHero';
export default EpisodeHero;

View file

@ -0,0 +1,400 @@
import React, { memo } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, Platform } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView as ExpoBlurView } from 'expo-blur';
import { MaterialIcons } from '@expo/vector-icons';
import { NavigationProp } from '@react-navigation/native';
import AnimatedImage from '../../../components/AnimatedImage';
import ProviderFilter from '../../../components/ProviderFilter';
import PulsingChip from '../../../components/PulsingChip';
import EpisodeHero from './EpisodeHero';
import MovieHero from './MovieHero';
import StreamsList from './StreamsList';
import { Stream } from '../../../types/metadata';
import { StreamSection, FilterItem, GroupedStreams, LoadingProviders, ScraperLogos } from '../types';
// Lazy-safe community blur import for Android
let AndroidBlurView: any = null;
if (Platform.OS === 'android') {
try {
AndroidBlurView = require('@react-native-community/blur').BlurView;
} catch (_) {
AndroidBlurView = null;
}
}
interface MobileStreamsLayoutProps {
// Navigation
navigation: NavigationProp<any>;
// Theme
currentTheme: any;
colors: any;
settings: any;
// Type
type: string;
// Metadata
metadata: any;
currentEpisode: any;
selectedEpisode: string | undefined;
// Movie hero
movieLogoError: boolean;
setMovieLogoError: (error: boolean) => void;
// Episode hero
episodeImage: string | null;
effectiveEpisodeVote: number;
effectiveEpisodeRuntime?: number;
hasIMDbRating: boolean;
gradientColors: [string, string, string, string, string];
// Backdrop
mobileBackdropSource: string | null | undefined;
// Streams
sections: StreamSection[];
streams: GroupedStreams;
filterItems: FilterItem[];
selectedProvider: string;
handleProviderChange: (provider: string) => void;
handleStreamPress: (stream: Stream) => void;
// Loading
loadingProviders: LoadingProviders;
loadingStreams: boolean;
loadingEpisodeStreams: boolean;
hasStremioStreamProviders: boolean;
streamsEmpty: boolean;
showInitialLoading: boolean;
showStillFetching: boolean;
showNoSourcesError: boolean;
// Autoplay
isAutoplayWaiting: boolean;
autoplayTriggered: boolean;
// Scrapers
activeFetchingScrapers: string[];
scraperLogos: ScraperLogos;
// Alert
openAlert: (title: string, message: string) => void;
// IDs
id: string;
imdbId?: string;
}
const MobileStreamsLayout = memo(
({
navigation,
currentTheme,
colors,
settings,
type,
metadata,
currentEpisode,
selectedEpisode,
movieLogoError,
setMovieLogoError,
episodeImage,
effectiveEpisodeVote,
effectiveEpisodeRuntime,
hasIMDbRating,
gradientColors,
mobileBackdropSource,
sections,
streams,
filterItems,
selectedProvider,
handleProviderChange,
handleStreamPress,
loadingProviders,
loadingStreams,
loadingEpisodeStreams,
hasStremioStreamProviders,
streamsEmpty,
showInitialLoading,
showStillFetching,
showNoSourcesError,
isAutoplayWaiting,
autoplayTriggered,
activeFetchingScrapers,
scraperLogos,
openAlert,
id,
imdbId,
}: MobileStreamsLayoutProps) => {
const styles = React.useMemo(() => createStyles(colors), [colors]);
const isEpisode = metadata?.videos && metadata.videos.length > 1 && selectedEpisode;
return (
<>
{/* Full Screen Background */}
{settings.enableStreamsBackdrop ? (
<View style={StyleSheet.absoluteFill}>
{mobileBackdropSource ? (
<AnimatedImage
source={{ uri: mobileBackdropSource }}
style={styles.mobileFullScreenBackground}
contentFit="cover"
/>
) : (
<View style={styles.mobileNoBackdropBackground} />
)}
{Platform.OS === 'android' && AndroidBlurView ? (
<AndroidBlurView
blurAmount={15}
blurRadius={25}
overlayColor="rgba(0,0,0,0.85)"
style={StyleSheet.absoluteFill}
/>
) : (
<ExpoBlurView intensity={60} tint="dark" style={StyleSheet.absoluteFill} />
)}
{Platform.OS === 'ios' && (
<View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(0,0,0,0.8)' }]} />
)}
</View>
) : (
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.darkBackground }]} />
)}
{/* Movie Hero */}
{type === 'movie' && metadata && (
<MovieHero
metadata={metadata}
movieLogoError={movieLogoError}
setMovieLogoError={setMovieLogoError}
colors={colors}
enableStreamsBackdrop={settings.enableStreamsBackdrop}
/>
)}
{/* Episode Hero */}
{currentEpisode && (
<EpisodeHero
episodeImage={episodeImage}
currentEpisode={currentEpisode}
effectiveEpisodeVote={effectiveEpisodeVote}
effectiveEpisodeRuntime={effectiveEpisodeRuntime}
hasIMDbRating={hasIMDbRating}
gradientColors={gradientColors}
colors={colors}
enableStreamsBackdrop={settings.enableStreamsBackdrop}
/>
)}
{/* Hero blend overlay for episodes */}
{isEpisode && (
<View style={styles.heroBlendOverlay}>
<LinearGradient
colors={
settings.enableStreamsBackdrop
? ['rgba(0,0,0,0.98)', 'rgba(0,0,0,0.85)', 'transparent']
: [colors.darkBackground, colors.darkBackground, 'transparent']
}
locations={[0, 0.4, 1]}
style={StyleSheet.absoluteFill}
/>
</View>
)}
{/* Main Content */}
<View
style={[
styles.streamsMainContent,
type === 'movie' && styles.streamsMainContentMovie,
!settings.enableStreamsBackdrop && { backgroundColor: colors.darkBackground },
]}
>
{/* Provider Filter */}
<View style={styles.filterContainer}>
{!streamsEmpty && (
<ProviderFilter
selectedProvider={selectedProvider}
providers={filterItems}
onSelect={handleProviderChange}
theme={currentTheme}
/>
)}
</View>
{/* Active Scrapers Status */}
{activeFetchingScrapers.length > 0 && (
<View style={styles.activeScrapersContainer}>
<Text style={styles.activeScrapersTitle}>Fetching from:</Text>
<View style={styles.activeScrapersRow}>
{activeFetchingScrapers.map((scraperName, index) => (
<PulsingChip key={scraperName} text={scraperName} delay={index * 200} />
))}
</View>
</View>
)}
{/* Content */}
{showNoSourcesError ? (
<View style={styles.noStreams}>
<MaterialIcons name="error-outline" size={48} color={colors.textMuted} />
<Text style={styles.noStreamsText}>No streaming sources available</Text>
<Text style={styles.noStreamsSubText}>Please add streaming sources in settings</Text>
<TouchableOpacity
style={styles.addSourcesButton}
onPress={() => navigation.navigate('Addons' as never)}
>
<Text style={styles.addSourcesButtonText}>Add Sources</Text>
</TouchableOpacity>
</View>
) : streamsEmpty ? (
showInitialLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.loadingText}>
{isAutoplayWaiting ? 'Finding best stream for autoplay...' : 'Finding available streams...'}
</Text>
</View>
) : showStillFetching ? (
<View style={styles.loadingContainer}>
<MaterialIcons name="hourglass-bottom" size={32} color={colors.primary} />
<Text style={styles.loadingText}>Still fetching streams</Text>
</View>
) : (
<View style={styles.noStreams}>
<MaterialIcons name="error-outline" size={48} color={colors.textMuted} />
<Text style={styles.noStreamsText}>No streams available</Text>
</View>
)
) : (
<StreamsList
sections={sections}
streams={streams}
loadingProviders={loadingProviders}
loadingStreams={loadingStreams}
loadingEpisodeStreams={loadingEpisodeStreams}
hasStremioStreamProviders={hasStremioStreamProviders}
isAutoplayWaiting={isAutoplayWaiting}
autoplayTriggered={autoplayTriggered}
handleStreamPress={handleStreamPress}
openAlert={openAlert}
settings={settings}
currentTheme={currentTheme}
colors={colors}
scraperLogos={scraperLogos}
metadata={metadata}
type={type}
currentEpisode={currentEpisode}
episodeImage={episodeImage}
id={id}
imdbId={imdbId}
/>
)}
</View>
</>
);
}
);
const createStyles = (colors: any) =>
StyleSheet.create({
mobileFullScreenBackground: {
...StyleSheet.absoluteFillObject,
width: '100%',
height: '100%',
},
mobileNoBackdropBackground: {
...StyleSheet.absoluteFillObject,
backgroundColor: colors.darkBackground,
},
heroBlendOverlay: {
position: 'absolute',
top: 140,
left: 0,
right: 0,
height: Platform.OS === 'android' ? 95 : 180,
zIndex: 0,
pointerEvents: 'none',
},
streamsMainContent: {
flex: 1,
backgroundColor: 'transparent',
paddingTop: 12,
zIndex: 1,
...(Platform.OS === 'ios' && {
opacity: 1,
shouldRasterizeIOS: false,
}),
},
streamsMainContentMovie: {
paddingTop: Platform.OS === 'android' ? 10 : 15,
},
filterContainer: {
paddingHorizontal: 12,
paddingBottom: 8,
},
activeScrapersContainer: {
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: 'transparent',
marginHorizontal: 16,
marginBottom: 4,
},
activeScrapersTitle: {
color: colors.mediumEmphasis,
fontSize: 12,
fontWeight: '500',
marginBottom: 6,
opacity: 0.8,
},
activeScrapersRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 4,
},
noStreams: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
noStreamsText: {
color: colors.textMuted,
fontSize: 16,
marginTop: 16,
},
noStreamsSubText: {
color: colors.mediumEmphasis,
fontSize: 14,
marginTop: 8,
textAlign: 'center',
},
addSourcesButton: {
marginTop: 24,
paddingHorizontal: 20,
paddingVertical: 10,
backgroundColor: colors.primary,
borderRadius: 8,
},
addSourcesButtonText: {
color: colors.white,
fontSize: 14,
fontWeight: '600',
},
loadingContainer: {
alignItems: 'center',
paddingVertical: 24,
},
loadingText: {
color: colors.primary,
fontSize: 12,
marginLeft: 4,
fontWeight: '500',
},
});
MobileStreamsLayout.displayName = 'MobileStreamsLayout';
export default MobileStreamsLayout;

View file

@ -0,0 +1,80 @@
import React, { memo } from 'react';
import { View, StyleSheet, Platform, Dimensions } from 'react-native';
import FastImage from '@d11/react-native-fast-image';
import AnimatedText from '../../../components/AnimatedText';
const { width } = Dimensions.get('window');
interface MovieHeroProps {
metadata: {
name: string;
logo?: string;
};
movieLogoError: boolean;
setMovieLogoError: (error: boolean) => void;
colors: any;
enableStreamsBackdrop: boolean;
}
const MovieHero = memo(
({ metadata, movieLogoError, setMovieLogoError, colors, enableStreamsBackdrop }: MovieHeroProps) => {
const styles = React.useMemo(() => createStyles(colors), [colors]);
return (
<View
style={[styles.container, !enableStreamsBackdrop && { backgroundColor: colors.darkBackground }]}
>
<View style={styles.content}>
{metadata.logo && !movieLogoError ? (
<FastImage
source={{ uri: metadata.logo }}
style={styles.logo}
resizeMode={FastImage.resizeMode.contain}
onError={() => setMovieLogoError(true)}
/>
) : (
<AnimatedText style={styles.title} numberOfLines={2}>
{metadata.name}
</AnimatedText>
)}
</View>
</View>
);
}
);
const createStyles = (colors: any) =>
StyleSheet.create({
container: {
width: '100%',
height: 140,
backgroundColor: 'transparent',
pointerEvents: 'box-none',
justifyContent: 'center',
paddingTop: Platform.OS === 'android' ? 65 : 35,
},
content: {
width: '100%',
height: 80,
alignItems: 'center',
justifyContent: 'center',
},
logo: {
width: '100%',
height: 80,
maxWidth: width * 0.85,
},
title: {
color: colors.highEmphasis,
fontSize: 28,
fontWeight: '900',
textAlign: 'center',
letterSpacing: -0.5,
paddingHorizontal: 20,
},
});
MovieHero.displayName = 'MovieHero';
export default MovieHero;

View file

@ -0,0 +1,272 @@
import React, { memo, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
FlatList,
ActivityIndicator,
Platform,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import StreamCard from '../../../components/StreamCard';
import { Stream } from '../../../types/metadata';
import { StreamSection, GroupedStreams, LoadingProviders, ScraperLogos } from '../types';
interface StreamsListProps {
sections: StreamSection[];
streams: GroupedStreams;
loadingProviders: LoadingProviders;
loadingStreams: boolean;
loadingEpisodeStreams: boolean;
hasStremioStreamProviders: boolean;
isAutoplayWaiting: boolean;
autoplayTriggered: boolean;
handleStreamPress: (stream: Stream) => void;
openAlert: (title: string, message: string) => void;
settings: any;
currentTheme: any;
colors: any;
scraperLogos: ScraperLogos;
metadata?: any;
type: string;
currentEpisode?: any;
episodeImage?: string | null;
id: string;
imdbId?: string;
}
const StreamsList = memo(
({
sections,
streams,
loadingProviders,
loadingStreams,
loadingEpisodeStreams,
hasStremioStreamProviders,
isAutoplayWaiting,
autoplayTriggered,
handleStreamPress,
openAlert,
settings,
currentTheme,
colors,
scraperLogos,
metadata,
type,
currentEpisode,
episodeImage,
id,
imdbId,
}: StreamsListProps) => {
const insets = useSafeAreaInsets();
const styles = React.useMemo(() => createStyles(colors), [colors]);
const renderSectionHeader = useCallback(
({ section }: { section: StreamSection }) => {
const isProviderLoading = loadingProviders[section.addonId];
return (
<View style={styles.sectionHeaderContainer}>
<View style={styles.sectionHeaderContent}>
<Text style={styles.streamGroupTitle}>{section.title}</Text>
{isProviderLoading && (
<View style={styles.sectionLoadingIndicator}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={[styles.sectionLoadingText, { color: colors.primary }]}>
Loading...
</Text>
</View>
)}
</View>
</View>
);
},
[loadingProviders, styles, colors.primary]
);
return (
<View collapsable={false} style={{ flex: 1 }}>
{/* Autoplay overlay */}
{isAutoplayWaiting && !autoplayTriggered && (
<View style={styles.autoplayOverlay}>
<View style={styles.autoplayIndicator}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.autoplayText}>Starting best stream...</Text>
</View>
</View>
)}
<ScrollView
style={styles.streamsContent}
contentContainerStyle={[
styles.streamsContainer,
{ paddingBottom: insets.bottom + 100 },
]}
showsVerticalScrollIndicator={false}
bounces={true}
overScrollMode="never"
{...(Platform.OS === 'ios' && {
removeClippedSubviews: false,
scrollEventThrottle: 16,
})}
>
{sections.filter(Boolean).map((section, sectionIndex) => (
<View key={section!.addonId || sectionIndex}>
{renderSectionHeader({ section: section! })}
{section!.data && section!.data.length > 0 ? (
<FlatList
data={section!.data}
keyExtractor={(item, index) => {
if (item && item.url) {
return `${item.url}-${sectionIndex}-${index}`;
}
return `empty-${sectionIndex}-${index}`;
}}
renderItem={({ item, index }) => (
<View>
<StreamCard
stream={item}
onPress={() => handleStreamPress(item)}
index={index}
isLoading={false}
statusMessage={undefined}
theme={currentTheme}
showLogos={settings.showScraperLogos}
scraperLogo={
(item.addonId && scraperLogos[item.addonId]) ||
((item as any).addon ? scraperLogos[(item.addonId || (item as any).addon) as string] || null : null)
}
showAlert={(t: string, m: string) => openAlert(t, m)}
parentTitle={metadata?.name}
parentType={type as 'movie' | 'series'}
parentSeason={
(type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined
}
parentEpisode={
(type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined
}
parentEpisodeTitle={
(type === 'series' || type === 'other') ? currentEpisode?.name : undefined
}
parentPosterUrl={episodeImage || metadata?.poster || undefined}
providerName={
streams &&
Object.keys(streams).find(pid =>
(streams as any)[pid]?.streams?.includes?.(item)
)
}
parentId={id}
parentImdbId={imdbId}
/>
</View>
)}
scrollEnabled={false}
initialNumToRender={6}
maxToRenderPerBatch={2}
windowSize={3}
removeClippedSubviews={true}
showsVerticalScrollIndicator={false}
getItemLayout={(data, index) => ({
length: 78,
offset: 78 * index,
index,
})}
/>
) : null}
</View>
))}
{/* Footer Loading */}
{(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders && (
<View style={styles.footerLoading}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.footerLoadingText}>Loading more sources...</Text>
</View>
)}
</ScrollView>
</View>
);
}
);
const createStyles = (colors: any) =>
StyleSheet.create({
streamsContent: {
flex: 1,
width: '100%',
zIndex: 2,
},
streamsContainer: {
paddingHorizontal: 12,
paddingBottom: 20,
width: '100%',
},
streamGroupTitle: {
color: colors.highEmphasis,
fontSize: 14,
fontWeight: '700',
marginBottom: 6,
marginTop: 0,
opacity: 0.9,
backgroundColor: 'transparent',
},
sectionHeaderContainer: {
paddingHorizontal: 12,
paddingVertical: 8,
},
sectionHeaderContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
sectionLoadingIndicator: {
flexDirection: 'row',
alignItems: 'center',
},
sectionLoadingText: {
marginLeft: 8,
},
autoplayOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
backgroundColor: 'rgba(0,0,0,0.8)',
padding: 16,
alignItems: 'center',
zIndex: 10,
},
autoplayIndicator: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.elevation2,
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 8,
},
autoplayText: {
color: colors.primary,
fontSize: 14,
marginLeft: 8,
fontWeight: '600',
},
footerLoading: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
},
footerLoadingText: {
color: colors.primary,
fontSize: 12,
marginLeft: 8,
fontWeight: '500',
},
});
StreamsList.displayName = 'StreamsList';
export default StreamsList;

View file

@ -0,0 +1,4 @@
export { default as EpisodeHero } from './EpisodeHero';
export { default as MovieHero } from './MovieHero';
export { default as StreamsList } from './StreamsList';
export { default as MobileStreamsLayout } from './MobileStreamsLayout';

View file

@ -0,0 +1,24 @@
import { Dimensions } from 'react-native';
const { width, height } = Dimensions.get('window');
export const SCREEN_WIDTH = width;
export const SCREEN_HEIGHT = height;
// Image URLs
export const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906';
export const IMDb_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/IMDB_Logo_2016.svg/575px-IMDB_Logo_2016.svg.png';
export const HDR_ICON = 'https://uxwing.com/wp-content/themes/uxwing/download/video-photography-multimedia/hdr-icon.png';
export 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';
// Timeouts
export const MKV_HEAD_TIMEOUT_MS = 600;
// Tablet breakpoint
export const TABLET_BREAKPOINT = 768;
// Hero section height
export const HERO_HEIGHT = 220;
// Movie title container height
export const MOVIE_TITLE_HEIGHT = 140;

View file

@ -0,0 +1,7 @@
export { StreamsScreen, default } from './StreamsScreen';
export { useStreamsScreen } from './useStreamsScreen';
export * from './types';
export * from './constants';
export * from './utils';
export { createStyles } from './styles';
export * from './components';

View file

@ -0,0 +1,367 @@
import { StyleSheet, Platform } from 'react-native';
import { SCREEN_WIDTH, HERO_HEIGHT, MOVIE_TITLE_HEIGHT } from './constants';
export const createStyles = (colors: any) =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'transparent',
...(Platform.OS === 'ios' && {
opacity: 1,
shouldRasterizeIOS: false,
renderToHardwareTextureAndroid: false,
}),
},
backButtonContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 2,
pointerEvents: 'box-none',
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 16,
paddingVertical: 12,
paddingTop: Platform.OS === 'android' ? 45 : 15,
backgroundColor: 'transparent',
},
backButtonText: {
color: colors.highEmphasis,
fontSize: 13,
fontWeight: '600',
},
streamsMainContent: {
flex: 1,
backgroundColor: 'transparent',
paddingTop: 12,
zIndex: 1,
...(Platform.OS === 'ios' && {
opacity: 1,
shouldRasterizeIOS: false,
}),
},
streamsMainContentMovie: {
paddingTop: Platform.OS === 'android' ? 10 : 15,
},
filterContainer: {
paddingHorizontal: 12,
paddingBottom: 8,
},
filterScroll: {
flexGrow: 0,
},
filterChip: {
backgroundColor: colors.elevation2,
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 16,
marginRight: 8,
borderWidth: 0,
},
filterChipSelected: {
backgroundColor: colors.primary,
},
filterChipText: {
color: colors.highEmphasis,
fontWeight: '600',
letterSpacing: 0.1,
},
filterChipTextSelected: {
color: colors.white,
fontWeight: '700',
},
streamsContent: {
flex: 1,
width: '100%',
zIndex: 2,
},
streamsContainer: {
paddingHorizontal: 12,
paddingBottom: 20,
width: '100%',
},
streamGroup: {
marginBottom: 24,
width: '100%',
},
streamGroupTitle: {
color: colors.highEmphasis,
fontSize: 14,
fontWeight: '700',
marginBottom: 6,
marginTop: 0,
opacity: 0.9,
backgroundColor: 'transparent',
},
noStreams: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
noStreamsText: {
color: colors.textMuted,
fontSize: 16,
marginTop: 16,
},
noStreamsSubText: {
color: colors.mediumEmphasis,
fontSize: 14,
marginTop: 8,
textAlign: 'center',
},
addSourcesButton: {
marginTop: 24,
paddingHorizontal: 20,
paddingVertical: 10,
backgroundColor: colors.primary,
borderRadius: 8,
},
addSourcesButtonText: {
color: colors.white,
fontSize: 14,
fontWeight: '600',
},
streamsHeroContainer: {
width: '100%',
height: HERO_HEIGHT,
marginBottom: 0,
position: 'relative',
backgroundColor: 'transparent',
pointerEvents: 'box-none',
zIndex: 1,
},
streamsHeroBackground: {
width: '100%',
height: '100%',
backgroundColor: 'transparent',
},
streamsHeroGradient: {
...StyleSheet.absoluteFillObject,
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',
marginTop: 0,
},
tmdbLogo: {
width: 20,
height: 14,
},
imdbLogo: {
width: 28,
height: 15,
},
streamsHeroRatingText: {
color: colors.highEmphasis,
fontSize: 13,
fontWeight: '700',
marginLeft: 4,
},
streamsHeroRuntime: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
streamsHeroRuntimeText: {
color: colors.mediumEmphasis,
fontSize: 13,
fontWeight: '600',
},
loadingContainer: {
alignItems: 'center',
paddingVertical: 24,
},
loadingText: {
color: colors.primary,
fontSize: 12,
marginLeft: 4,
fontWeight: '500',
},
footerLoading: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
},
footerLoadingText: {
color: colors.primary,
fontSize: 12,
marginLeft: 8,
fontWeight: '500',
},
movieTitleContainer: {
width: '100%',
height: MOVIE_TITLE_HEIGHT,
backgroundColor: 'transparent',
pointerEvents: 'box-none',
justifyContent: 'center',
paddingTop: Platform.OS === 'android' ? 65 : 35,
},
movieTitleContent: {
width: '100%',
height: 80,
alignItems: 'center',
justifyContent: 'center',
},
movieLogo: {
width: '100%',
height: 80,
maxWidth: SCREEN_WIDTH * 0.85,
},
movieTitle: {
color: colors.highEmphasis,
fontSize: 28,
fontWeight: '900',
textAlign: 'center',
letterSpacing: -0.5,
paddingHorizontal: 20,
},
sectionHeaderContainer: {
paddingHorizontal: 12,
paddingVertical: 8,
},
sectionHeaderContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
sectionLoadingIndicator: {
flexDirection: 'row',
alignItems: 'center',
},
sectionLoadingText: {
marginLeft: 8,
},
autoplayOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
backgroundColor: 'rgba(0,0,0,0.8)',
padding: 16,
alignItems: 'center',
zIndex: 10,
},
autoplayIndicator: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.elevation2,
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 8,
},
autoplayText: {
color: colors.primary,
fontSize: 14,
marginLeft: 8,
fontWeight: '600',
},
activeScrapersContainer: {
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: 'transparent',
marginHorizontal: 16,
marginBottom: 4,
},
activeScrapersTitle: {
color: colors.mediumEmphasis,
fontSize: 12,
fontWeight: '500',
marginBottom: 6,
opacity: 0.8,
},
activeScrapersRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 4,
},
backButtonContainerTablet: {
zIndex: 3,
},
mobileFullScreenBackground: {
...StyleSheet.absoluteFillObject,
width: '100%',
height: '100%',
},
mobileNoBackdropBackground: {
...StyleSheet.absoluteFillObject,
backgroundColor: colors.darkBackground,
},
heroBlendOverlay: {
position: 'absolute',
top: 140,
left: 0,
right: 0,
height: Platform.OS === 'android' ? 95 : 180,
zIndex: 0,
pointerEvents: 'none',
},
chip: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 12,
marginRight: 6,
marginBottom: 6,
backgroundColor: colors.elevation2,
},
chipText: {
color: colors.highEmphasis,
fontSize: 11,
fontWeight: '600',
letterSpacing: 0.2,
},
});

View file

@ -0,0 +1,71 @@
import { Stream } from '../../types/metadata';
export interface StreamProviderData {
addonName: string;
streams: Stream[];
}
export interface GroupedStreams {
[addonId: string]: StreamProviderData;
}
export interface StreamSection {
title: string;
addonId: string;
data: Stream[];
isEmptyDueToQualityFilter?: boolean;
}
export interface FilterItem {
id: string;
name: string;
}
export interface ProviderStatus {
loading: boolean;
success: boolean;
error: boolean;
message: string;
timeStarted: number;
timeCompleted: number;
}
export interface LoadingProviders {
[key: string]: boolean;
}
export interface ProviderStatusMap {
[key: string]: ProviderStatus;
}
export interface ProviderLoadTimes {
[key: string]: number;
}
export interface ScraperLogos {
[key: string]: string;
}
export interface IMDbRatingsMap {
[key: string]: number;
}
export interface TMDBEpisodeOverride {
vote_average?: number;
runtime?: number;
still_path?: string;
}
export interface AlertAction {
label: string;
onPress: () => void;
style?: object;
}
export interface StreamsScreenParams {
id: string;
type: 'movie' | 'series' | 'tv' | 'other';
episodeId?: string;
episodeThumbnail?: string;
fromPlayer?: boolean;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,203 @@
import { Stream } from '../../types/metadata';
import { MKV_HEAD_TIMEOUT_MS } from './constants';
/**
* Language variations for filtering
*/
const LANGUAGE_VARIATIONS: Record<string, string[]> = {
latin: ['latino', 'latina', 'lat'],
spanish: ['español', 'espanol', 'spa'],
german: ['deutsch', 'ger'],
french: ['français', 'francais', 'fre'],
portuguese: ['português', 'portugues', 'por'],
italian: ['ita'],
english: ['eng'],
japanese: ['jap'],
korean: ['kor'],
chinese: ['chi', 'cn'],
arabic: ['ara'],
russian: ['rus'],
turkish: ['tur'],
hindi: ['hin'],
};
/**
* Get all variations of a language name
*/
const getLanguageVariations = (language: string): string[] => {
const langLower = language.toLowerCase();
const variations = [langLower];
if (LANGUAGE_VARIATIONS[langLower]) {
variations.push(...LANGUAGE_VARIATIONS[langLower]);
}
return variations;
};
/**
* Filter streams by excluded quality settings
*/
export const filterStreamsByQuality = (
streams: Stream[],
excludedQualities: string[]
): Stream[] => {
if (!excludedQualities || excludedQualities.length === 0) {
return streams;
}
return streams.filter(stream => {
const streamTitle = stream.title || stream.name || '';
const hasExcludedQuality = excludedQualities.some(excludedQuality => {
if (excludedQuality === 'Auto') {
return /\b(auto|adaptive)\b/i.test(streamTitle);
} else {
const pattern = new RegExp(excludedQuality.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
return pattern.test(streamTitle);
}
});
return !hasExcludedQuality;
});
};
/**
* Filter streams by excluded language settings
*/
export const filterStreamsByLanguage = (
streams: Stream[],
excludedLanguages: string[]
): Stream[] => {
if (!excludedLanguages || excludedLanguages.length === 0) {
return streams;
}
return streams.filter(stream => {
const streamName = stream.name || '';
const streamTitle = stream.title || '';
const streamDescription = stream.description || '';
const searchText = `${streamName} ${streamTitle} ${streamDescription}`.toLowerCase();
const hasExcludedLanguage = excludedLanguages.some(excludedLanguage => {
const variations = getLanguageVariations(excludedLanguage);
return variations.some(variant => searchText.includes(variant));
});
return !hasExcludedLanguage;
});
};
/**
* Extract numeric quality from stream title
*/
export const getQualityNumeric = (title: string | undefined): number => {
if (!title) return 0;
// Check for 4K first (treat as 2160p)
if (/\b4k\b/i.test(title)) {
return 2160;
}
const matchWithP = title.match(/(\d+)p/i);
if (matchWithP) return parseInt(matchWithP[1], 10);
const qualityPatterns = [/\b(240|360|480|720|1080|1440|2160|4320|8000)\b/i];
for (const pattern of qualityPatterns) {
const match = title.match(pattern);
if (match) {
const quality = parseInt(match[1], 10);
if (quality >= 240 && quality <= 8000) return quality;
}
}
return 0;
};
/**
* Sort streams by quality (highest first)
*/
export const sortStreamsByQuality = (streams: Stream[]): Stream[] => {
return [...streams].sort((a, b) => {
const titleA = (a.name || a.title || '').toLowerCase();
const titleB = (b.name || b.title || '').toLowerCase();
// Check for "Auto" quality - always prioritize it
const isAutoA = /\b(auto|adaptive)\b/i.test(titleA);
const isAutoB = /\b(auto|adaptive)\b/i.test(titleB);
if (isAutoA && !isAutoB) return -1;
if (!isAutoA && isAutoB) return 1;
const qualityA = getQualityNumeric(a.name || a.title);
const qualityB = getQualityNumeric(b.name || b.title);
if (qualityA !== qualityB) {
return qualityB - qualityA;
}
// If quality is the same, sort by provider name, then stream name
const providerA = a.addonId || a.addonName || '';
const providerB = b.addonId || b.addonName || '';
if (providerA !== providerB) {
return providerA.localeCompare(providerB);
}
const nameA = (a.name || a.title || '').toLowerCase();
const nameB = (b.name || b.title || '').toLowerCase();
return nameA.localeCompare(nameB);
});
};
/**
* Detect MKV format via HEAD request
*/
export const detectMkvViaHead = async (
url: string,
headers?: Record<string, string>
): Promise<boolean> => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), MKV_HEAD_TIMEOUT_MS);
try {
const res = await fetch(url, {
method: 'HEAD',
headers,
signal: controller.signal as any,
} as any);
const contentType = res.headers.get('content-type') || '';
return /matroska|x-matroska/i.test(contentType);
} catch (_e) {
return false;
} finally {
clearTimeout(timeout);
}
};
/**
* Infer video type from URL
*/
export const inferVideoTypeFromUrl = (url?: string): string | undefined => {
if (!url) return undefined;
const lower = url.toLowerCase();
if (/(\.|ext=)(m3u8)(\b|$)/i.test(lower)) return 'm3u8';
if (/(\.|ext=)(mpd)(\b|$)/i.test(lower)) return 'mpd';
if (/(\.|ext=)(mp4)(\b|$)/i.test(lower)) return 'mp4';
return undefined;
};
/**
* Filter headers for Vidrock compatibility
*/
export const filterHeadersForVidrock = (
headers: Record<string, string> | undefined
): Record<string, string> | undefined => {
if (!headers) return undefined;
const essentialHeaders: Record<string, string> = {};
if (headers['User-Agent']) essentialHeaders['User-Agent'] = headers['User-Agent'];
if (headers['Referer']) essentialHeaders['Referer'] = headers['Referer'];
if (headers['Origin']) essentialHeaders['Origin'] = headers['Origin'];
return Object.keys(essentialHeaders).length > 0 ? essentialHeaders : undefined;
};