mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
refactor streamscreen
This commit is contained in:
parent
7885df341e
commit
aed4fed56f
13 changed files with 2975 additions and 2872 deletions
File diff suppressed because it is too large
Load diff
215
src/screens/streams/StreamsScreen.tsx
Normal file
215
src/screens/streams/StreamsScreen.tsx
Normal 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);
|
||||
226
src/screens/streams/components/EpisodeHero.tsx
Normal file
226
src/screens/streams/components/EpisodeHero.tsx
Normal 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;
|
||||
400
src/screens/streams/components/MobileStreamsLayout.tsx
Normal file
400
src/screens/streams/components/MobileStreamsLayout.tsx
Normal 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;
|
||||
80
src/screens/streams/components/MovieHero.tsx
Normal file
80
src/screens/streams/components/MovieHero.tsx
Normal 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;
|
||||
272
src/screens/streams/components/StreamsList.tsx
Normal file
272
src/screens/streams/components/StreamsList.tsx
Normal 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;
|
||||
4
src/screens/streams/components/index.ts
Normal file
4
src/screens/streams/components/index.ts
Normal 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';
|
||||
24
src/screens/streams/constants.ts
Normal file
24
src/screens/streams/constants.ts
Normal 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;
|
||||
7
src/screens/streams/index.ts
Normal file
7
src/screens/streams/index.ts
Normal 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';
|
||||
367
src/screens/streams/styles.ts
Normal file
367
src/screens/streams/styles.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
71
src/screens/streams/types.ts
Normal file
71
src/screens/streams/types.ts
Normal 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;
|
||||
}
|
||||
1092
src/screens/streams/useStreamsScreen.ts
Normal file
1092
src/screens/streams/useStreamsScreen.ts
Normal file
File diff suppressed because it is too large
Load diff
203
src/screens/streams/utils.ts
Normal file
203
src/screens/streams/utils.ts
Normal 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;
|
||||
};
|
||||
Loading…
Reference in a new issue