mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-21 00:32:04 +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