refactor streamscreen
This commit is contained in:
parent
8d74b7e7ce
commit
b97481f2d9
8 changed files with 1318 additions and 804 deletions
56
src/components/AnimatedImage.tsx
Normal file
56
src/components/AnimatedImage.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import React, { memo, useEffect } from 'react';
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
import Animated, {
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
withTiming
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import FastImage from '@d11/react-native-fast-image';
|
||||||
|
|
||||||
|
interface AnimatedImageProps {
|
||||||
|
source: { uri: string } | undefined;
|
||||||
|
style: any;
|
||||||
|
contentFit: any;
|
||||||
|
onLoad?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AnimatedImage = memo(({
|
||||||
|
source,
|
||||||
|
style,
|
||||||
|
contentFit,
|
||||||
|
onLoad
|
||||||
|
}: AnimatedImageProps) => {
|
||||||
|
const opacity = useSharedValue(0);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: opacity.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (source?.uri) {
|
||||||
|
opacity.value = withTiming(1, { duration: 300 });
|
||||||
|
} else {
|
||||||
|
opacity.value = 0;
|
||||||
|
}
|
||||||
|
}, [source?.uri]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
opacity.value = 0;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[style, animatedStyle]}>
|
||||||
|
<FastImage
|
||||||
|
source={source}
|
||||||
|
style={StyleSheet.absoluteFillObject}
|
||||||
|
resizeMode={FastImage.resizeMode.cover}
|
||||||
|
onLoad={onLoad}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default AnimatedImage;
|
||||||
50
src/components/AnimatedText.tsx
Normal file
50
src/components/AnimatedText.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import React, { memo, useEffect } from 'react';
|
||||||
|
import Animated, {
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
withTiming,
|
||||||
|
withDelay
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
|
||||||
|
interface AnimatedTextProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
style: any;
|
||||||
|
delay?: number;
|
||||||
|
numberOfLines?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AnimatedText = memo(({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
delay = 0,
|
||||||
|
numberOfLines
|
||||||
|
}: AnimatedTextProps) => {
|
||||||
|
const opacity = useSharedValue(0);
|
||||||
|
const translateY = useSharedValue(20);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: opacity.value,
|
||||||
|
transform: [{ translateY: translateY.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
opacity.value = withDelay(delay, withTiming(1, { duration: 250 }));
|
||||||
|
translateY.value = withDelay(delay, withTiming(0, { duration: 250 }));
|
||||||
|
}, [delay]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
opacity.value = 0;
|
||||||
|
translateY.value = 20;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.Text style={[style, animatedStyle]} numberOfLines={numberOfLines}>
|
||||||
|
{children}
|
||||||
|
</Animated.Text>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default AnimatedText;
|
||||||
48
src/components/AnimatedView.tsx
Normal file
48
src/components/AnimatedView.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import React, { memo, useEffect } from 'react';
|
||||||
|
import Animated, {
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
withTiming,
|
||||||
|
withDelay
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
|
||||||
|
interface AnimatedViewProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
style?: any;
|
||||||
|
delay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AnimatedView = memo(({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
delay = 0
|
||||||
|
}: AnimatedViewProps) => {
|
||||||
|
const opacity = useSharedValue(0);
|
||||||
|
const translateY = useSharedValue(20);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: opacity.value,
|
||||||
|
transform: [{ translateY: translateY.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
opacity.value = withDelay(delay, withTiming(1, { duration: 250 }));
|
||||||
|
translateY.value = withDelay(delay, withTiming(0, { duration: 250 }));
|
||||||
|
}, [delay]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
opacity.value = 0;
|
||||||
|
translateY.value = 20;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[style, animatedStyle]}>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default AnimatedView;
|
||||||
88
src/components/ProviderFilter.tsx
Normal file
88
src/components/ProviderFilter.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import React, { memo, useCallback } from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity, FlatList } from 'react-native';
|
||||||
|
|
||||||
|
interface ProviderFilterProps {
|
||||||
|
selectedProvider: string;
|
||||||
|
providers: Array<{ id: string; name: string; }>;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
theme: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProviderFilter = memo(({
|
||||||
|
selectedProvider,
|
||||||
|
providers,
|
||||||
|
onSelect,
|
||||||
|
theme
|
||||||
|
}: ProviderFilterProps) => {
|
||||||
|
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
|
||||||
|
|
||||||
|
const renderItem = useCallback(({ item, index }: { item: { id: string; name: string }; index: number }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.filterChip,
|
||||||
|
selectedProvider === item.id && styles.filterChipSelected
|
||||||
|
]}
|
||||||
|
onPress={() => onSelect(item.id)}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
styles.filterChipText,
|
||||||
|
selectedProvider === item.id && styles.filterChipTextSelected
|
||||||
|
]}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
), [selectedProvider, onSelect, styles]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<FlatList
|
||||||
|
data={providers}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.filterScroll}
|
||||||
|
bounces={true}
|
||||||
|
overScrollMode="never"
|
||||||
|
decelerationRate="fast"
|
||||||
|
initialNumToRender={5}
|
||||||
|
maxToRenderPerBatch={3}
|
||||||
|
windowSize={3}
|
||||||
|
removeClippedSubviews={true}
|
||||||
|
getItemLayout={(data, index) => ({
|
||||||
|
length: 100, // Approximate width of each item
|
||||||
|
offset: 100 * index,
|
||||||
|
index,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const createStyles = (colors: any) => StyleSheet.create({
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ProviderFilter;
|
||||||
36
src/components/PulsingChip.tsx
Normal file
36
src/components/PulsingChip.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
|
|
||||||
|
interface PulsingChipProps {
|
||||||
|
text: string;
|
||||||
|
delay: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PulsingChip = memo(({ text, delay }: PulsingChipProps) => {
|
||||||
|
const { currentTheme } = useTheme();
|
||||||
|
const styles = React.useMemo(() => createStyles(currentTheme.colors), [currentTheme.colors]);
|
||||||
|
// Make chip static to avoid continuous animation load
|
||||||
|
return (
|
||||||
|
<View style={styles.activeScraperChip}>
|
||||||
|
<Text style={styles.activeScraperText}>{text}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const createStyles = (colors: any) => StyleSheet.create({
|
||||||
|
activeScraperChip: {
|
||||||
|
backgroundColor: colors.elevation2,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 0,
|
||||||
|
},
|
||||||
|
activeScraperText: {
|
||||||
|
color: colors.mediumEmphasis,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '400',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default PulsingChip;
|
||||||
373
src/components/StreamCard.tsx
Normal file
373
src/components/StreamCard.tsx
Normal file
|
|
@ -0,0 +1,373 @@
|
||||||
|
import React, { memo, useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
Platform,
|
||||||
|
Clipboard,
|
||||||
|
} from 'react-native';
|
||||||
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
|
import FastImage from '@d11/react-native-fast-image';
|
||||||
|
import { Stream } from '../types/metadata';
|
||||||
|
import QualityBadge from './metadata/QualityBadge';
|
||||||
|
import { useSettings } from '../hooks/useSettings';
|
||||||
|
import { useDownloads } from '../contexts/DownloadsContext';
|
||||||
|
import { useToast } from '../contexts/ToastContext';
|
||||||
|
|
||||||
|
interface StreamCardProps {
|
||||||
|
stream: Stream;
|
||||||
|
onPress: () => void;
|
||||||
|
index: number;
|
||||||
|
isLoading?: boolean;
|
||||||
|
statusMessage?: string;
|
||||||
|
theme: any;
|
||||||
|
showLogos?: boolean;
|
||||||
|
scraperLogo?: string | null;
|
||||||
|
showAlert: (title: string, message: string) => void;
|
||||||
|
parentTitle?: string;
|
||||||
|
parentType?: 'movie' | 'series';
|
||||||
|
parentSeason?: number;
|
||||||
|
parentEpisode?: number;
|
||||||
|
parentEpisodeTitle?: string;
|
||||||
|
parentPosterUrl?: string | null;
|
||||||
|
providerName?: string;
|
||||||
|
parentId?: string;
|
||||||
|
parentImdbId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StreamCard = memo(({
|
||||||
|
stream,
|
||||||
|
onPress,
|
||||||
|
index,
|
||||||
|
isLoading,
|
||||||
|
statusMessage,
|
||||||
|
theme,
|
||||||
|
showLogos,
|
||||||
|
scraperLogo,
|
||||||
|
showAlert,
|
||||||
|
parentTitle,
|
||||||
|
parentType,
|
||||||
|
parentSeason,
|
||||||
|
parentEpisode,
|
||||||
|
parentEpisodeTitle,
|
||||||
|
parentPosterUrl,
|
||||||
|
providerName,
|
||||||
|
parentId,
|
||||||
|
parentImdbId
|
||||||
|
}: StreamCardProps) => {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const { startDownload } = useDownloads();
|
||||||
|
const { showSuccess, showInfo } = useToast();
|
||||||
|
|
||||||
|
// Handle long press to copy stream URL to clipboard
|
||||||
|
const handleLongPress = useCallback(async () => {
|
||||||
|
if (stream.url) {
|
||||||
|
try {
|
||||||
|
await Clipboard.setString(stream.url);
|
||||||
|
|
||||||
|
// Use toast for Android, custom alert for iOS
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
showSuccess('URL Copied', 'Stream URL copied to clipboard!');
|
||||||
|
} else {
|
||||||
|
// iOS uses custom alert
|
||||||
|
showAlert('Copied!', 'Stream URL has been copied to clipboard.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback: show URL in alert if clipboard fails
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
showInfo('Stream URL', `Stream URL: ${stream.url}`);
|
||||||
|
} else {
|
||||||
|
showAlert('Stream URL', stream.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [stream.url, showAlert, showSuccess, showInfo]);
|
||||||
|
|
||||||
|
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
|
||||||
|
|
||||||
|
const streamInfo = useMemo(() => {
|
||||||
|
const title = stream.title || '';
|
||||||
|
const name = stream.name || '';
|
||||||
|
|
||||||
|
// Helper function to format size from bytes
|
||||||
|
const formatSize = (bytes: number): string => {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get size from title (legacy format) or from stream.size field
|
||||||
|
let sizeDisplay = title.match(/💾\s*([\d.]+\s*[GM]B)/)?.[1];
|
||||||
|
if (!sizeDisplay && stream.size && typeof stream.size === 'number' && stream.size > 0) {
|
||||||
|
sizeDisplay = formatSize(stream.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract quality for badge display
|
||||||
|
const basicQuality = title.match(/(\d+)p/)?.[1] || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
quality: basicQuality,
|
||||||
|
isHDR: title.toLowerCase().includes('hdr'),
|
||||||
|
isDolby: title.toLowerCase().includes('dolby') || title.includes('DV'),
|
||||||
|
size: sizeDisplay,
|
||||||
|
isDebrid: stream.behaviorHints?.cached,
|
||||||
|
displayName: name || 'Unnamed Stream',
|
||||||
|
subTitle: title && title !== name ? title : null
|
||||||
|
};
|
||||||
|
}, [stream.name, stream.title, stream.behaviorHints, stream.size]);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const url = stream.url;
|
||||||
|
if (!url) return;
|
||||||
|
// Prevent duplicate downloads for the same exact URL
|
||||||
|
try {
|
||||||
|
const downloadsModule = require('../contexts/DownloadsContext');
|
||||||
|
if (downloadsModule && downloadsModule.isDownloadingUrl && downloadsModule.isDownloadingUrl(url)) {
|
||||||
|
showAlert('Already Downloading', 'This download has already started for this exact link.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
// Show immediate feedback on both platforms
|
||||||
|
showAlert('Starting Download', 'Download will be started.');
|
||||||
|
const parent: any = stream as any;
|
||||||
|
const inferredTitle = parentTitle || stream.name || stream.title || parent.metaName || 'Content';
|
||||||
|
const inferredType: 'movie' | 'series' = parentType || (parent.kind === 'series' || parent.type === 'series' ? 'series' : 'movie');
|
||||||
|
const season = typeof parentSeason === 'number' ? parentSeason : (parent.season || parent.season_number);
|
||||||
|
const episode = typeof parentEpisode === 'number' ? parentEpisode : (parent.episode || parent.episode_number);
|
||||||
|
const episodeTitle = parentEpisodeTitle || parent.episodeTitle || parent.episode_name;
|
||||||
|
// Prefer the stream's display name (often includes provider + resolution)
|
||||||
|
const provider = (stream.name as any) || (stream.title as any) || providerName || parent.addonName || parent.addonId || (stream.addonName as any) || (stream.addonId as any) || 'Provider';
|
||||||
|
|
||||||
|
// Use parentId first (from route params), fallback to stream metadata
|
||||||
|
const idForContent = parentId || parent.imdbId || parent.tmdbId || parent.addonId || inferredTitle;
|
||||||
|
|
||||||
|
// Extract tmdbId if available (from parentId or parent metadata)
|
||||||
|
let tmdbId: number | undefined = undefined;
|
||||||
|
if (parentId && parentId.startsWith('tmdb:')) {
|
||||||
|
tmdbId = parseInt(parentId.split(':')[1], 10);
|
||||||
|
} else if (typeof parent.tmdbId === 'number') {
|
||||||
|
tmdbId = parent.tmdbId;
|
||||||
|
}
|
||||||
|
|
||||||
|
await startDownload({
|
||||||
|
id: String(idForContent),
|
||||||
|
type: inferredType,
|
||||||
|
title: String(inferredTitle),
|
||||||
|
providerName: String(provider),
|
||||||
|
season: inferredType === 'series' ? (season ? Number(season) : undefined) : undefined,
|
||||||
|
episode: inferredType === 'series' ? (episode ? Number(episode) : undefined) : undefined,
|
||||||
|
episodeTitle: inferredType === 'series' ? (episodeTitle ? String(episodeTitle) : undefined) : undefined,
|
||||||
|
quality: streamInfo.quality || undefined,
|
||||||
|
posterUrl: parentPosterUrl || parent.poster || parent.backdrop || null,
|
||||||
|
url,
|
||||||
|
headers: (stream.headers as any) || undefined,
|
||||||
|
// Pass metadata for progress tracking
|
||||||
|
imdbId: parentImdbId || parent.imdbId || undefined,
|
||||||
|
tmdbId: tmdbId,
|
||||||
|
});
|
||||||
|
showAlert('Download Started', 'Your download has been added to the queue.');
|
||||||
|
} catch {}
|
||||||
|
}, [startDownload, stream.url, stream.headers, streamInfo.quality, showAlert, stream.name, stream.title, parentId, parentImdbId, parentTitle, parentType, parentSeason, parentEpisode, parentEpisodeTitle, parentPosterUrl, providerName]);
|
||||||
|
|
||||||
|
const isDebrid = streamInfo.isDebrid;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.streamCard,
|
||||||
|
isLoading && styles.streamCardLoading,
|
||||||
|
isDebrid && styles.streamCardHighlighted
|
||||||
|
]}
|
||||||
|
onPress={onPress}
|
||||||
|
onLongPress={handleLongPress}
|
||||||
|
disabled={isLoading}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{/* Scraper Logo */}
|
||||||
|
{showLogos && scraperLogo && (
|
||||||
|
<View style={styles.scraperLogoContainer}>
|
||||||
|
<FastImage
|
||||||
|
source={{ uri: scraperLogo }}
|
||||||
|
style={styles.scraperLogo}
|
||||||
|
resizeMode={FastImage.resizeMode.contain}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.streamDetails}>
|
||||||
|
<View style={styles.streamNameRow}>
|
||||||
|
<View style={styles.streamTitleContainer}>
|
||||||
|
<Text style={[styles.streamName, { color: theme.colors.highEmphasis }]}>
|
||||||
|
{streamInfo.displayName}
|
||||||
|
</Text>
|
||||||
|
{streamInfo.subTitle && (
|
||||||
|
<Text style={[styles.streamAddonName, { color: theme.colors.mediumEmphasis }]}>
|
||||||
|
{streamInfo.subTitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Show loading indicator if stream is loading */}
|
||||||
|
{isLoading && (
|
||||||
|
<View style={styles.loadingIndicator}>
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.primary} />
|
||||||
|
<Text style={[styles.loadingText, { color: theme.colors.primary }]}>
|
||||||
|
{statusMessage || "Loading..."}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.streamMetaRow}>
|
||||||
|
{streamInfo.isDolby && (
|
||||||
|
<QualityBadge type="VISION" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{streamInfo.size && (
|
||||||
|
<View style={[styles.chip, { backgroundColor: theme.colors.darkGray }]}>
|
||||||
|
<Text style={[styles.chipText, { color: theme.colors.white }]}>💾 {streamInfo.size}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{streamInfo.isDebrid && (
|
||||||
|
<View style={[styles.chip, { backgroundColor: theme.colors.success }]}>
|
||||||
|
<Text style={[styles.chipText, { color: theme.colors.white }]}>DEBRID</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
|
||||||
|
{settings?.enableDownloads !== false && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.streamAction, { marginLeft: 8, backgroundColor: theme.colors.elevation2 }]}
|
||||||
|
onPress={handleDownload}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="download"
|
||||||
|
size={20}
|
||||||
|
color={theme.colors.highEmphasis}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const createStyles = (colors: any) => StyleSheet.create({
|
||||||
|
streamCard: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
padding: 14,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 10,
|
||||||
|
minHeight: 68,
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderWidth: 0,
|
||||||
|
width: '100%',
|
||||||
|
zIndex: 1,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.04,
|
||||||
|
shadowRadius: 2,
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
elevation: 0,
|
||||||
|
},
|
||||||
|
scraperLogoContainer: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
marginRight: 12,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.elevation2,
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
scraperLogo: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
},
|
||||||
|
streamCardLoading: {
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
streamCardHighlighted: {
|
||||||
|
backgroundColor: colors.elevation2,
|
||||||
|
shadowOpacity: 0.18,
|
||||||
|
},
|
||||||
|
streamDetails: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
streamNameRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
width: '100%',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8
|
||||||
|
},
|
||||||
|
streamTitleContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
streamName: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 2,
|
||||||
|
lineHeight: 20,
|
||||||
|
color: colors.highEmphasis,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
},
|
||||||
|
streamAddonName: {
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 18,
|
||||||
|
color: colors.mediumEmphasis,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
streamMetaRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 4,
|
||||||
|
marginBottom: 6,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
loadingIndicator: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontSize: 12,
|
||||||
|
marginLeft: 4,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
streamAction: {
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 15,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default StreamCard;
|
||||||
624
src/components/TabletStreamsLayout.tsx
Normal file
624
src/components/TabletStreamsLayout.tsx
Normal file
|
|
@ -0,0 +1,624 @@
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
} from 'react-native';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import FastImage from '@d11/react-native-fast-image';
|
||||||
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
|
import { BlurView as ExpoBlurView } from 'expo-blur';
|
||||||
|
|
||||||
|
// Lazy-safe community blur import for Android
|
||||||
|
let AndroidBlurView: any = null;
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
AndroidBlurView = require('@react-native-community/blur').BlurView;
|
||||||
|
} catch (_) {
|
||||||
|
AndroidBlurView = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
import { Stream } from '../types/metadata';
|
||||||
|
import { RootStackNavigationProp } from '../navigation/AppNavigator';
|
||||||
|
import ProviderFilter from './ProviderFilter';
|
||||||
|
import PulsingChip from './PulsingChip';
|
||||||
|
import StreamCard from './StreamCard';
|
||||||
|
import AnimatedImage from './AnimatedImage';
|
||||||
|
|
||||||
|
interface TabletStreamsLayoutProps {
|
||||||
|
// Background and content props
|
||||||
|
episodeImage?: string | null;
|
||||||
|
bannerImage?: string | null;
|
||||||
|
metadata?: any;
|
||||||
|
type: string;
|
||||||
|
currentEpisode?: any;
|
||||||
|
|
||||||
|
// Movie logo props
|
||||||
|
movieLogoError: boolean;
|
||||||
|
setMovieLogoError: (error: boolean) => void;
|
||||||
|
|
||||||
|
// Stream-related props
|
||||||
|
streamsEmpty: boolean;
|
||||||
|
selectedProvider: string;
|
||||||
|
filterItems: Array<{ id: string; name: string; }>;
|
||||||
|
handleProviderChange: (provider: string) => void;
|
||||||
|
activeFetchingScrapers: string[];
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
isAutoplayWaiting: boolean;
|
||||||
|
autoplayTriggered: boolean;
|
||||||
|
showNoSourcesError: boolean;
|
||||||
|
showInitialLoading: boolean;
|
||||||
|
showStillFetching: boolean;
|
||||||
|
|
||||||
|
// Stream rendering props
|
||||||
|
sections: Array<{ title: string; addonId: string; data: Stream[]; isEmptyDueToQualityFilter?: boolean } | null>;
|
||||||
|
renderSectionHeader: ({ section }: { section: { title: string; addonId: string; isEmptyDueToQualityFilter?: boolean } }) => React.ReactElement;
|
||||||
|
handleStreamPress: (stream: Stream) => void;
|
||||||
|
openAlert: (title: string, message: string) => void;
|
||||||
|
|
||||||
|
// Settings and theme
|
||||||
|
settings: any;
|
||||||
|
currentTheme: any;
|
||||||
|
colors: any;
|
||||||
|
|
||||||
|
// Other props
|
||||||
|
navigation: RootStackNavigationProp;
|
||||||
|
insets: any;
|
||||||
|
streams: any;
|
||||||
|
scraperLogos: Record<string, string>;
|
||||||
|
id: string;
|
||||||
|
imdbId?: string;
|
||||||
|
loadingStreams: boolean;
|
||||||
|
loadingEpisodeStreams: boolean;
|
||||||
|
hasStremioStreamProviders: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
||||||
|
episodeImage,
|
||||||
|
bannerImage,
|
||||||
|
metadata,
|
||||||
|
type,
|
||||||
|
currentEpisode,
|
||||||
|
movieLogoError,
|
||||||
|
setMovieLogoError,
|
||||||
|
streamsEmpty,
|
||||||
|
selectedProvider,
|
||||||
|
filterItems,
|
||||||
|
handleProviderChange,
|
||||||
|
activeFetchingScrapers,
|
||||||
|
isAutoplayWaiting,
|
||||||
|
autoplayTriggered,
|
||||||
|
showNoSourcesError,
|
||||||
|
showInitialLoading,
|
||||||
|
showStillFetching,
|
||||||
|
sections,
|
||||||
|
renderSectionHeader,
|
||||||
|
handleStreamPress,
|
||||||
|
openAlert,
|
||||||
|
settings,
|
||||||
|
currentTheme,
|
||||||
|
colors,
|
||||||
|
navigation,
|
||||||
|
insets,
|
||||||
|
streams,
|
||||||
|
scraperLogos,
|
||||||
|
id,
|
||||||
|
imdbId,
|
||||||
|
loadingStreams,
|
||||||
|
loadingEpisodeStreams,
|
||||||
|
hasStremioStreamProviders,
|
||||||
|
}) => {
|
||||||
|
const styles = React.useMemo(() => createStyles(colors), [colors]);
|
||||||
|
|
||||||
|
const renderStreamContent = () => {
|
||||||
|
if (showNoSourcesError) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.noStreams, { paddingTop: 50 }]}>
|
||||||
|
<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')}
|
||||||
|
>
|
||||||
|
<Text style={styles.addSourcesButtonText}>Add Sources</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamsEmpty) {
|
||||||
|
if (showInitialLoading || showStillFetching) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.loadingContainer, { paddingTop: 50 }]}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
<Text style={styles.loadingText}>
|
||||||
|
{isAutoplayWaiting ? 'Finding best stream for autoplay...' :
|
||||||
|
showStillFetching ? 'Still fetching streams…' :
|
||||||
|
'Finding available streams...'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<View style={[styles.noStreams, { paddingTop: 50 }]}>
|
||||||
|
<MaterialIcons name="error-outline" size={48} color={colors.textMuted} />
|
||||||
|
<Text style={styles.noStreamsText}>No streams available</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.streamsContent}
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.streamsContainer,
|
||||||
|
{ paddingBottom: insets.bottom + 100 }
|
||||||
|
]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
bounces={true}
|
||||||
|
overScrollMode="never"
|
||||||
|
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, m) => 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 || undefined}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
scrollEnabled={false}
|
||||||
|
initialNumToRender={6}
|
||||||
|
maxToRenderPerBatch={2}
|
||||||
|
windowSize={3}
|
||||||
|
removeClippedSubviews={true}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
getItemLayout={(data, index) => ({
|
||||||
|
length: 78,
|
||||||
|
offset: 78 * index,
|
||||||
|
index,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders && (
|
||||||
|
<View style={styles.footerLoading}>
|
||||||
|
<ActivityIndicator size="small" color={colors.primary} />
|
||||||
|
<Text style={styles.footerLoadingText}>Loading more sources...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.tabletLayout}>
|
||||||
|
{/* Full Screen Background */}
|
||||||
|
<AnimatedImage
|
||||||
|
source={episodeImage ? { uri: episodeImage } : bannerImage ? { uri: bannerImage } : metadata?.poster ? { uri: metadata.poster } : undefined}
|
||||||
|
style={styles.tabletFullScreenBackground}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['rgba(0,0,0,0.2)', 'rgba(0,0,0,0.4)', 'rgba(0,0,0,0.6)']}
|
||||||
|
locations={[0, 0.5, 1]}
|
||||||
|
style={styles.tabletFullScreenGradient}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Left Panel: Movie Logo/Episode Info */}
|
||||||
|
<View style={styles.tabletLeftPanel}>
|
||||||
|
{type === 'movie' && metadata && (
|
||||||
|
<View style={styles.tabletMovieLogoContainer}>
|
||||||
|
{metadata.logo && !movieLogoError ? (
|
||||||
|
<FastImage
|
||||||
|
source={{ uri: metadata.logo }}
|
||||||
|
style={styles.tabletMovieLogo}
|
||||||
|
resizeMode={FastImage.resizeMode.contain}
|
||||||
|
onError={() => setMovieLogoError(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.tabletMovieTitle}>{metadata.name}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === 'series' && currentEpisode && (
|
||||||
|
<View style={styles.tabletEpisodeInfo}>
|
||||||
|
<Text style={[styles.streamsHeroEpisodeNumber, styles.tabletEpisodeText, styles.tabletEpisodeNumber]}>{currentEpisode.episodeString}</Text>
|
||||||
|
<Text style={[styles.streamsHeroTitle, styles.tabletEpisodeText, styles.tabletEpisodeTitle]} numberOfLines={2}>{currentEpisode.name}</Text>
|
||||||
|
{currentEpisode.overview && (
|
||||||
|
<Text style={[styles.streamsHeroOverview, styles.tabletEpisodeText, styles.tabletEpisodeOverview]} numberOfLines={4}>{currentEpisode.overview}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Right Panel: Streams List */}
|
||||||
|
<View style={styles.tabletRightPanel}>
|
||||||
|
{Platform.OS === 'android' && AndroidBlurView ? (
|
||||||
|
<AndroidBlurView
|
||||||
|
blurAmount={15}
|
||||||
|
blurRadius={8}
|
||||||
|
style={[
|
||||||
|
styles.streamsMainContent,
|
||||||
|
styles.tabletStreamsContent,
|
||||||
|
type === 'movie' && styles.streamsMainContentMovie
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.tabletBlurContent}>
|
||||||
|
{/* Always show filter container to prevent layout shift */}
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stream content area - always show ScrollView to prevent flash */}
|
||||||
|
<View collapsable={false} style={{ flex: 1 }}>
|
||||||
|
{/* Show autoplay loading overlay if waiting for autoplay */}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderStreamContent()}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</AndroidBlurView>
|
||||||
|
) : (
|
||||||
|
<ExpoBlurView
|
||||||
|
intensity={80}
|
||||||
|
tint="dark"
|
||||||
|
style={[
|
||||||
|
styles.streamsMainContent,
|
||||||
|
styles.tabletStreamsContent,
|
||||||
|
type === 'movie' && styles.streamsMainContentMovie
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.tabletBlurContent}>
|
||||||
|
{/* Always show filter container to prevent layout shift */}
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stream content area - always show ScrollView to prevent flash */}
|
||||||
|
<View collapsable={false} style={{ flex: 1 }}>
|
||||||
|
{/* Show autoplay loading overlay if waiting for autoplay */}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderStreamContent()}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ExpoBlurView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a function to generate styles with the current theme colors
|
||||||
|
const createStyles = (colors: any) => StyleSheet.create({
|
||||||
|
streamsMainContent: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
paddingTop: 12,
|
||||||
|
zIndex: 1,
|
||||||
|
// iOS-specific fixes for navigation transition glitches
|
||||||
|
...(Platform.OS === 'ios' && {
|
||||||
|
// Ensure proper rendering during transitions
|
||||||
|
opacity: 1,
|
||||||
|
// Prevent iOS optimization that can cause glitches
|
||||||
|
shouldRasterizeIOS: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
streamsMainContentMovie: {
|
||||||
|
paddingTop: Platform.OS === 'android' ? 10 : 15,
|
||||||
|
},
|
||||||
|
filterContainer: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingBottom: 8,
|
||||||
|
},
|
||||||
|
streamsContent: {
|
||||||
|
flex: 1,
|
||||||
|
width: '100%',
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
streamsContainer: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingBottom: 20,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
footerLoading: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
footerLoadingText: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontSize: 12,
|
||||||
|
marginLeft: 8,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
// Tablet-specific styles
|
||||||
|
tabletLayout: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
tabletFullScreenBackground: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
},
|
||||||
|
tabletFullScreenGradient: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
},
|
||||||
|
tabletLeftPanel: {
|
||||||
|
width: '40%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 24,
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
tabletMovieLogoContainer: {
|
||||||
|
width: '80%',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
tabletMovieLogo: {
|
||||||
|
width: '100%',
|
||||||
|
height: 120,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
tabletMovieTitle: {
|
||||||
|
color: colors.highEmphasis,
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: '900',
|
||||||
|
textAlign: 'center',
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
textShadowColor: 'rgba(0,0,0,0.8)',
|
||||||
|
textShadowOffset: { width: 0, height: 2 },
|
||||||
|
textShadowRadius: 4,
|
||||||
|
},
|
||||||
|
tabletEpisodeInfo: {
|
||||||
|
width: '80%',
|
||||||
|
},
|
||||||
|
tabletEpisodeText: {
|
||||||
|
textShadowColor: 'rgba(0,0,0,1)',
|
||||||
|
textShadowOffset: { width: 0, height: 0 },
|
||||||
|
textShadowRadius: 4,
|
||||||
|
},
|
||||||
|
tabletEpisodeNumber: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
tabletEpisodeTitle: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 12,
|
||||||
|
lineHeight: 34,
|
||||||
|
},
|
||||||
|
tabletEpisodeOverview: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
opacity: 0.95,
|
||||||
|
},
|
||||||
|
tabletRightPanel: {
|
||||||
|
width: '60%',
|
||||||
|
flex: 1,
|
||||||
|
paddingTop: Platform.OS === 'android' ? 60 : 20,
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
tabletStreamsContent: {
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.2)',
|
||||||
|
borderRadius: 24,
|
||||||
|
margin: 12,
|
||||||
|
overflow: 'hidden', // Ensures content respects rounded corners
|
||||||
|
},
|
||||||
|
tabletBlurContent: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default memo(TabletStreamsLayout);
|
||||||
|
|
@ -52,6 +52,13 @@ import { useDownloads } from '../contexts/DownloadsContext';
|
||||||
import { streamCacheService } from '../services/streamCacheService';
|
import { streamCacheService } from '../services/streamCacheService';
|
||||||
import { PaperProvider } from 'react-native-paper';
|
import { PaperProvider } from 'react-native-paper';
|
||||||
import { BlurView as ExpoBlurView } from 'expo-blur';
|
import { BlurView as ExpoBlurView } from 'expo-blur';
|
||||||
|
import TabletStreamsLayout from '../components/TabletStreamsLayout';
|
||||||
|
import ProviderFilter from '../components/ProviderFilter';
|
||||||
|
import PulsingChip from '../components/PulsingChip';
|
||||||
|
import StreamCard from '../components/StreamCard';
|
||||||
|
import AnimatedImage from '../components/AnimatedImage';
|
||||||
|
import AnimatedText from '../components/AnimatedText';
|
||||||
|
import AnimatedView from '../components/AnimatedView';
|
||||||
|
|
||||||
// Lazy-safe community blur import for Android
|
// Lazy-safe community blur import for Android
|
||||||
let AndroidBlurView: any = null;
|
let AndroidBlurView: any = null;
|
||||||
|
|
@ -96,350 +103,8 @@ const detectMkvViaHead = async (url: string, headers?: Record<string, string>) =
|
||||||
};
|
};
|
||||||
|
|
||||||
// Animated Components
|
// Animated Components
|
||||||
const AnimatedImage = memo(({
|
|
||||||
source,
|
|
||||||
style,
|
|
||||||
contentFit,
|
|
||||||
onLoad
|
|
||||||
}: {
|
|
||||||
source: { uri: string } | undefined;
|
|
||||||
style: any;
|
|
||||||
contentFit: any;
|
|
||||||
onLoad?: () => void;
|
|
||||||
}) => {
|
|
||||||
const opacity = useSharedValue(0);
|
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
|
||||||
opacity: opacity.value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (source?.uri) {
|
|
||||||
opacity.value = withTiming(1, { duration: 300 });
|
|
||||||
} else {
|
|
||||||
opacity.value = 0;
|
|
||||||
}
|
|
||||||
}, [source?.uri]);
|
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
opacity.value = 0;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.View style={[style, animatedStyle]}>
|
|
||||||
<FastImage
|
|
||||||
source={source}
|
|
||||||
style={StyleSheet.absoluteFillObject}
|
|
||||||
resizeMode={FastImage.resizeMode.cover}
|
|
||||||
onLoad={onLoad}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const AnimatedText = memo(({
|
|
||||||
children,
|
|
||||||
style,
|
|
||||||
delay = 0,
|
|
||||||
numberOfLines
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
style: any;
|
|
||||||
delay?: number;
|
|
||||||
numberOfLines?: number;
|
|
||||||
}) => {
|
|
||||||
const opacity = useSharedValue(0);
|
|
||||||
const translateY = useSharedValue(20);
|
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
|
||||||
opacity: opacity.value,
|
|
||||||
transform: [{ translateY: translateY.value }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
opacity.value = withDelay(delay, withTiming(1, { duration: 250 }));
|
|
||||||
translateY.value = withDelay(delay, withTiming(0, { duration: 250 }));
|
|
||||||
}, [delay]);
|
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
opacity.value = 0;
|
|
||||||
translateY.value = 20;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.Text style={[style, animatedStyle]} numberOfLines={numberOfLines}>
|
|
||||||
{children}
|
|
||||||
</Animated.Text>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const AnimatedView = memo(({
|
|
||||||
children,
|
|
||||||
style,
|
|
||||||
delay = 0
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
style?: any;
|
|
||||||
delay?: number;
|
|
||||||
}) => {
|
|
||||||
const opacity = useSharedValue(0);
|
|
||||||
const translateY = useSharedValue(20);
|
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
|
||||||
opacity: opacity.value,
|
|
||||||
transform: [{ translateY: translateY.value }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
opacity.value = withDelay(delay, withTiming(1, { duration: 250 }));
|
|
||||||
translateY.value = withDelay(delay, withTiming(0, { duration: 250 }));
|
|
||||||
}, [delay]);
|
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
opacity.value = 0;
|
|
||||||
translateY.value = 20;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.View style={[style, animatedStyle]}>
|
|
||||||
{children}
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extracted Components
|
// Extracted Components
|
||||||
const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, theme, showLogos, scraperLogo, showAlert, parentTitle, parentType, parentSeason, parentEpisode, parentEpisodeTitle, parentPosterUrl, providerName, parentId, parentImdbId }: {
|
|
||||||
stream: Stream;
|
|
||||||
onPress: () => void;
|
|
||||||
index: number;
|
|
||||||
isLoading?: boolean;
|
|
||||||
statusMessage?: string;
|
|
||||||
theme: any;
|
|
||||||
showLogos?: boolean;
|
|
||||||
scraperLogo?: string | null;
|
|
||||||
showAlert: (title: string, message: string) => void;
|
|
||||||
parentTitle?: string;
|
|
||||||
parentType?: 'movie' | 'series';
|
|
||||||
parentSeason?: number;
|
|
||||||
parentEpisode?: number;
|
|
||||||
parentEpisodeTitle?: string;
|
|
||||||
parentPosterUrl?: string | null;
|
|
||||||
providerName?: string;
|
|
||||||
parentId?: string; // Content ID (e.g., tt0903747 or tmdb:1396)
|
|
||||||
parentImdbId?: string; // IMDb ID if available
|
|
||||||
}) => {
|
|
||||||
const { useSettings } = require('../hooks/useSettings');
|
|
||||||
const { settings } = useSettings();
|
|
||||||
const { startDownload } = useDownloads();
|
|
||||||
const { showSuccess, showInfo } = useToast();
|
|
||||||
|
|
||||||
// Handle long press to copy stream URL to clipboard
|
|
||||||
const handleLongPress = useCallback(async () => {
|
|
||||||
if (stream.url) {
|
|
||||||
try {
|
|
||||||
await Clipboard.setString(stream.url);
|
|
||||||
|
|
||||||
// Use toast for Android, custom alert for iOS
|
|
||||||
if (Platform.OS === 'android') {
|
|
||||||
showSuccess('URL Copied', 'Stream URL copied to clipboard!');
|
|
||||||
} else {
|
|
||||||
// iOS uses custom alert
|
|
||||||
showAlert('Copied!', 'Stream URL has been copied to clipboard.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Fallback: show URL in alert if clipboard fails
|
|
||||||
if (Platform.OS === 'android') {
|
|
||||||
showInfo('Stream URL', `Stream URL: ${stream.url}`);
|
|
||||||
} else {
|
|
||||||
showAlert('Stream URL', stream.url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [stream.url, showAlert, showSuccess, showInfo]);
|
|
||||||
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
|
|
||||||
|
|
||||||
const streamInfo = useMemo(() => {
|
|
||||||
const title = stream.title || '';
|
|
||||||
const name = stream.name || '';
|
|
||||||
|
|
||||||
// Helper function to format size from bytes
|
|
||||||
const formatSize = (bytes: number): string => {
|
|
||||||
if (bytes === 0) return '0 Bytes';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get size from title (legacy format) or from stream.size field
|
|
||||||
let sizeDisplay = title.match(/💾\s*([\d.]+\s*[GM]B)/)?.[1];
|
|
||||||
if (!sizeDisplay && stream.size && typeof stream.size === 'number' && stream.size > 0) {
|
|
||||||
sizeDisplay = formatSize(stream.size);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract quality for badge display
|
|
||||||
const basicQuality = title.match(/(\d+)p/)?.[1] || null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
quality: basicQuality,
|
|
||||||
isHDR: title.toLowerCase().includes('hdr'),
|
|
||||||
isDolby: title.toLowerCase().includes('dolby') || title.includes('DV'),
|
|
||||||
size: sizeDisplay,
|
|
||||||
isDebrid: stream.behaviorHints?.cached,
|
|
||||||
displayName: name || 'Unnamed Stream',
|
|
||||||
subTitle: title && title !== name ? title : null
|
|
||||||
};
|
|
||||||
}, [stream.name, stream.title, stream.behaviorHints, stream.size]);
|
|
||||||
|
|
||||||
// Logo is provided by parent to avoid per-card async work
|
|
||||||
|
|
||||||
const handleDownload = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const url = stream.url;
|
|
||||||
if (!url) return;
|
|
||||||
// Prevent duplicate downloads for the same exact URL
|
|
||||||
try {
|
|
||||||
const downloadsModule = require('../contexts/DownloadsContext');
|
|
||||||
if (downloadsModule && downloadsModule.isDownloadingUrl && downloadsModule.isDownloadingUrl(url)) {
|
|
||||||
showAlert('Already Downloading', 'This download has already started for this exact link.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
// Show immediate feedback on both platforms
|
|
||||||
showAlert('Starting Download', 'Download will be started.');
|
|
||||||
const parent: any = stream as any;
|
|
||||||
const inferredTitle = parentTitle || stream.name || stream.title || parent.metaName || 'Content';
|
|
||||||
const inferredType: 'movie' | 'series' = parentType || (parent.kind === 'series' || parent.type === 'series' ? 'series' : 'movie');
|
|
||||||
const season = typeof parentSeason === 'number' ? parentSeason : (parent.season || parent.season_number);
|
|
||||||
const episode = typeof parentEpisode === 'number' ? parentEpisode : (parent.episode || parent.episode_number);
|
|
||||||
const episodeTitle = parentEpisodeTitle || parent.episodeTitle || parent.episode_name;
|
|
||||||
// Prefer the stream's display name (often includes provider + resolution)
|
|
||||||
const provider = (stream.name as any) || (stream.title as any) || providerName || parent.addonName || parent.addonId || (stream.addonName as any) || (stream.addonId as any) || 'Provider';
|
|
||||||
|
|
||||||
// Use parentId first (from route params), fallback to stream metadata
|
|
||||||
const idForContent = parentId || parent.imdbId || parent.tmdbId || parent.addonId || inferredTitle;
|
|
||||||
|
|
||||||
// Extract tmdbId if available (from parentId or parent metadata)
|
|
||||||
let tmdbId: number | undefined = undefined;
|
|
||||||
if (parentId && parentId.startsWith('tmdb:')) {
|
|
||||||
tmdbId = parseInt(parentId.split(':')[1], 10);
|
|
||||||
} else if (typeof parent.tmdbId === 'number') {
|
|
||||||
tmdbId = parent.tmdbId;
|
|
||||||
}
|
|
||||||
|
|
||||||
await startDownload({
|
|
||||||
id: String(idForContent),
|
|
||||||
type: inferredType,
|
|
||||||
title: String(inferredTitle),
|
|
||||||
providerName: String(provider),
|
|
||||||
season: inferredType === 'series' ? (season ? Number(season) : undefined) : undefined,
|
|
||||||
episode: inferredType === 'series' ? (episode ? Number(episode) : undefined) : undefined,
|
|
||||||
episodeTitle: inferredType === 'series' ? (episodeTitle ? String(episodeTitle) : undefined) : undefined,
|
|
||||||
quality: streamInfo.quality || undefined,
|
|
||||||
posterUrl: parentPosterUrl || parent.poster || parent.backdrop || null,
|
|
||||||
url,
|
|
||||||
headers: (stream.headers as any) || undefined,
|
|
||||||
// Pass metadata for progress tracking
|
|
||||||
imdbId: parentImdbId || parent.imdbId || undefined,
|
|
||||||
tmdbId: tmdbId,
|
|
||||||
});
|
|
||||||
showAlert('Download Started', 'Your download has been added to the queue.');
|
|
||||||
} catch {}
|
|
||||||
}, [startDownload, stream.url, stream.headers, streamInfo.quality, showAlert, stream.name, stream.title, parentId, parentImdbId, parentTitle, parentType, parentSeason, parentEpisode, parentEpisodeTitle, parentPosterUrl, providerName]);
|
|
||||||
|
|
||||||
const isDebrid = streamInfo.isDebrid;
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.streamCard,
|
|
||||||
isLoading && styles.streamCardLoading,
|
|
||||||
isDebrid && styles.streamCardHighlighted
|
|
||||||
]}
|
|
||||||
onPress={onPress}
|
|
||||||
onLongPress={handleLongPress}
|
|
||||||
disabled={isLoading}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
{/* Scraper Logo */}
|
|
||||||
{showLogos && scraperLogo && (
|
|
||||||
<View style={styles.scraperLogoContainer}>
|
|
||||||
<FastImage
|
|
||||||
source={{ uri: scraperLogo }}
|
|
||||||
style={styles.scraperLogo}
|
|
||||||
resizeMode={FastImage.resizeMode.contain}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={styles.streamDetails}>
|
|
||||||
<View style={styles.streamNameRow}>
|
|
||||||
<View style={styles.streamTitleContainer}>
|
|
||||||
<Text style={[styles.streamName, { color: theme.colors.highEmphasis }]}>
|
|
||||||
{streamInfo.displayName}
|
|
||||||
</Text>
|
|
||||||
{streamInfo.subTitle && (
|
|
||||||
<Text style={[styles.streamAddonName, { color: theme.colors.mediumEmphasis }]}>
|
|
||||||
{streamInfo.subTitle}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Show loading indicator if stream is loading */}
|
|
||||||
{isLoading && (
|
|
||||||
<View style={styles.loadingIndicator}>
|
|
||||||
<ActivityIndicator size="small" color={theme.colors.primary} />
|
|
||||||
<Text style={[styles.loadingText, { color: theme.colors.primary }]}>
|
|
||||||
{statusMessage || "Loading..."}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.streamMetaRow}>
|
|
||||||
{streamInfo.isDolby && (
|
|
||||||
<QualityBadge type="VISION" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{streamInfo.size && (
|
|
||||||
<View style={[styles.chip, { backgroundColor: theme.colors.darkGray }]}>
|
|
||||||
<Text style={[styles.chipText, { color: theme.colors.white }]}>💾 {streamInfo.size}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{streamInfo.isDebrid && (
|
|
||||||
<View style={[styles.chip, { backgroundColor: theme.colors.success }]}>
|
|
||||||
<Text style={[styles.chipText, { color: theme.colors.white }]}>DEBRID</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
|
|
||||||
{settings?.enableDownloads !== false && (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.streamAction, { marginLeft: 8, backgroundColor: theme.colors.elevation2 }]}
|
|
||||||
onPress={handleDownload}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<MaterialIcons
|
|
||||||
name="download"
|
|
||||||
size={20}
|
|
||||||
color={theme.colors.highEmphasis}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const QualityTag = React.memo(({ text, color, theme }: { text: string; color: string; theme: any }) => {
|
const QualityTag = React.memo(({ text, color, theme }: { text: string; color: string; theme: any }) => {
|
||||||
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
|
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
|
||||||
|
|
@ -451,72 +116,7 @@ const QualityTag = React.memo(({ text, color, theme }: { text: string; color: st
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const PulsingChip = memo(({ text, delay }: { text: string; delay: number }) => {
|
|
||||||
const { currentTheme } = useTheme();
|
|
||||||
const styles = React.useMemo(() => createStyles(currentTheme.colors), [currentTheme.colors]);
|
|
||||||
// Make chip static to avoid continuous animation load
|
|
||||||
return (
|
|
||||||
<View style={styles.activeScraperChip}>
|
|
||||||
<Text style={styles.activeScraperText}>{text}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ProviderFilter = memo(({
|
|
||||||
selectedProvider,
|
|
||||||
providers,
|
|
||||||
onSelect,
|
|
||||||
theme
|
|
||||||
}: {
|
|
||||||
selectedProvider: string;
|
|
||||||
providers: Array<{ id: string; name: string; }>;
|
|
||||||
onSelect: (id: string) => void;
|
|
||||||
theme: any;
|
|
||||||
}) => {
|
|
||||||
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
|
|
||||||
|
|
||||||
const renderItem = useCallback(({ item, index }: { item: { id: string; name: string }; index: number }) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.filterChip,
|
|
||||||
selectedProvider === item.id && styles.filterChipSelected
|
|
||||||
]}
|
|
||||||
onPress={() => onSelect(item.id)}
|
|
||||||
>
|
|
||||||
<Text style={[
|
|
||||||
styles.filterChipText,
|
|
||||||
selectedProvider === item.id && styles.filterChipTextSelected
|
|
||||||
]}>
|
|
||||||
{item.name}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
), [selectedProvider, onSelect, styles]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<FlatList
|
|
||||||
data={providers}
|
|
||||||
renderItem={renderItem}
|
|
||||||
keyExtractor={item => item.id}
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
style={styles.filterScroll}
|
|
||||||
bounces={true}
|
|
||||||
overScrollMode="never"
|
|
||||||
decelerationRate="fast"
|
|
||||||
initialNumToRender={5}
|
|
||||||
maxToRenderPerBatch={3}
|
|
||||||
windowSize={3}
|
|
||||||
removeClippedSubviews={true}
|
|
||||||
getItemLayout={(data, index) => ({
|
|
||||||
length: 100, // Approximate width of each item
|
|
||||||
offset: 100 * index,
|
|
||||||
index,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export const StreamsScreen = () => {
|
export const StreamsScreen = () => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
@ -2056,403 +1656,41 @@ export const StreamsScreen = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isTablet ? (
|
{isTablet ? (
|
||||||
// TABLET LAYOUT - Full Screen Background
|
<TabletStreamsLayout
|
||||||
<View style={styles.tabletLayout}>
|
episodeImage={episodeImage}
|
||||||
{/* Full Screen Background */}
|
bannerImage={bannerImage}
|
||||||
<AnimatedImage
|
metadata={metadata}
|
||||||
source={episodeImage ? { uri: episodeImage } : bannerImage ? { uri: bannerImage } : metadata?.poster ? { uri: metadata.poster } : undefined}
|
type={type}
|
||||||
style={styles.tabletFullScreenBackground}
|
currentEpisode={currentEpisode}
|
||||||
contentFit="cover"
|
movieLogoError={movieLogoError}
|
||||||
/>
|
setMovieLogoError={setMovieLogoError}
|
||||||
<LinearGradient
|
streamsEmpty={streamsEmpty}
|
||||||
colors={['rgba(0,0,0,0.2)', 'rgba(0,0,0,0.4)', 'rgba(0,0,0,0.6)']}
|
selectedProvider={selectedProvider}
|
||||||
locations={[0, 0.5, 1]}
|
filterItems={filterItems}
|
||||||
style={styles.tabletFullScreenGradient}
|
handleProviderChange={handleProviderChange}
|
||||||
/>
|
activeFetchingScrapers={activeFetchingScrapers}
|
||||||
|
isAutoplayWaiting={isAutoplayWaiting}
|
||||||
{/* Left Panel: Movie Logo/Episode Info */}
|
autoplayTriggered={autoplayTriggered}
|
||||||
<View style={styles.tabletLeftPanel}>
|
showNoSourcesError={showNoSourcesError}
|
||||||
{type === 'movie' && metadata && (
|
showInitialLoading={showInitialLoading}
|
||||||
<View style={styles.tabletMovieLogoContainer}>
|
showStillFetching={showStillFetching}
|
||||||
{metadata.logo && !movieLogoError ? (
|
sections={sections}
|
||||||
<FastImage
|
renderSectionHeader={renderSectionHeader}
|
||||||
source={{ uri: metadata.logo }}
|
handleStreamPress={handleStreamPress}
|
||||||
style={styles.tabletMovieLogo}
|
openAlert={openAlert}
|
||||||
resizeMode={FastImage.resizeMode.contain}
|
settings={settings}
|
||||||
onError={() => setMovieLogoError(true)}
|
currentTheme={currentTheme}
|
||||||
/>
|
colors={colors}
|
||||||
) : (
|
navigation={navigation}
|
||||||
<Text style={styles.tabletMovieTitle}>{metadata.name}</Text>
|
insets={insets}
|
||||||
)}
|
streams={streams}
|
||||||
</View>
|
scraperLogos={scraperLogos}
|
||||||
)}
|
id={id}
|
||||||
|
imdbId={imdbId || undefined}
|
||||||
{type === 'series' && currentEpisode && (
|
loadingStreams={loadingStreams}
|
||||||
<View style={styles.tabletEpisodeInfo}>
|
loadingEpisodeStreams={loadingEpisodeStreams}
|
||||||
<Text style={[styles.streamsHeroEpisodeNumber, styles.tabletEpisodeText, styles.tabletEpisodeNumber]}>{currentEpisode.episodeString}</Text>
|
hasStremioStreamProviders={hasStremioStreamProviders}
|
||||||
<Text style={[styles.streamsHeroTitle, styles.tabletEpisodeText, styles.tabletEpisodeTitle]} numberOfLines={2}>{currentEpisode.name}</Text>
|
/>
|
||||||
{currentEpisode.overview && (
|
|
||||||
<Text style={[styles.streamsHeroOverview, styles.tabletEpisodeText, styles.tabletEpisodeOverview]} numberOfLines={4}>{currentEpisode.overview}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Right Panel: Streams List */}
|
|
||||||
<View style={styles.tabletRightPanel}>
|
|
||||||
{Platform.OS === 'android' && AndroidBlurView ? (
|
|
||||||
<AndroidBlurView
|
|
||||||
blurAmount={15}
|
|
||||||
blurRadius={8}
|
|
||||||
style={[
|
|
||||||
styles.streamsMainContent,
|
|
||||||
styles.tabletStreamsContent,
|
|
||||||
type === 'movie' && styles.streamsMainContentMovie
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View style={styles.tabletBlurContent}>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Update the streams/loading state display logic */}
|
|
||||||
{ 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')}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
// No streams and not loading = no streams available
|
|
||||||
<View
|
|
||||||
style={styles.noStreams}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="error-outline" size={48} color={colors.textMuted} />
|
|
||||||
<Text style={styles.noStreamsText}>No streams available</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
// Show streams immediately when available, even if still loading others
|
|
||||||
<View collapsable={false} style={{ flex: 1 }}>
|
|
||||||
{/* Show autoplay loading overlay if waiting for autoplay */}
|
|
||||||
{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 } // Add safe area + extra padding
|
|
||||||
]}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
bounces={true}
|
|
||||||
overScrollMode="never"
|
|
||||||
scrollEventThrottle={16}
|
|
||||||
>
|
|
||||||
{sections.filter(Boolean).map((section, sectionIndex) => (
|
|
||||||
<View key={section!.addonId || sectionIndex}>
|
|
||||||
{/* Section Header */}
|
|
||||||
{renderSectionHeader({ section: section! })}
|
|
||||||
|
|
||||||
{/* Stream Cards using FlatList */}
|
|
||||||
{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 ? scraperLogoCache.get((item.addonId || (item as any).addon) as string) || null : null}
|
|
||||||
showAlert={(t, m) => 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 || undefined}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
scrollEnabled={false}
|
|
||||||
initialNumToRender={6}
|
|
||||||
maxToRenderPerBatch={2}
|
|
||||||
windowSize={3}
|
|
||||||
removeClippedSubviews={true}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
getItemLayout={(data, index) => ({
|
|
||||||
length: 78, // Approximate height of StreamCard (68 minHeight + 10 marginBottom)
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</AndroidBlurView>
|
|
||||||
) : (
|
|
||||||
<ExpoBlurView
|
|
||||||
intensity={80}
|
|
||||||
tint="dark"
|
|
||||||
style={[
|
|
||||||
styles.streamsMainContent,
|
|
||||||
styles.tabletStreamsContent,
|
|
||||||
type === 'movie' && styles.streamsMainContentMovie
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View style={styles.tabletBlurContent}>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Update the streams/loading state display logic */}
|
|
||||||
{ 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')}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
// No streams and not loading = no streams available
|
|
||||||
<View
|
|
||||||
style={styles.noStreams}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="error-outline" size={48} color={colors.textMuted} />
|
|
||||||
<Text style={styles.noStreamsText}>No streams available</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
// Show streams immediately when available, even if still loading others
|
|
||||||
<View collapsable={false} style={{ flex: 1 }}>
|
|
||||||
{/* Show autoplay loading overlay if waiting for autoplay */}
|
|
||||||
{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 } // Add safe area + extra padding
|
|
||||||
]}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
bounces={true}
|
|
||||||
overScrollMode="never"
|
|
||||||
// iOS-specific fixes for navigation transition glitches
|
|
||||||
{...(Platform.OS === 'ios' && {
|
|
||||||
// Ensure proper rendering during transitions
|
|
||||||
removeClippedSubviews: false, // Prevent iOS from clipping views during transitions
|
|
||||||
// Force hardware acceleration for smoother transitions
|
|
||||||
scrollEventThrottle: 16,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{sections.filter(Boolean).map((section, sectionIndex) => (
|
|
||||||
<View key={section!.addonId || sectionIndex}>
|
|
||||||
{/* Section Header */}
|
|
||||||
{renderSectionHeader({ section: section! })}
|
|
||||||
|
|
||||||
{/* Stream Cards using FlatList */}
|
|
||||||
{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 ? scraperLogoCache.get((item.addonId || (item as any).addon) as string) || null : null}
|
|
||||||
showAlert={(t, m) => 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 || undefined}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
scrollEnabled={false}
|
|
||||||
initialNumToRender={6}
|
|
||||||
maxToRenderPerBatch={2}
|
|
||||||
windowSize={3}
|
|
||||||
removeClippedSubviews={true}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
getItemLayout={(data, index) => ({
|
|
||||||
length: 78, // Approximate height of StreamCard (68 minHeight + 10 marginBottom)
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</ExpoBlurView>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : (
|
) : (
|
||||||
// PHONE LAYOUT (existing structure)
|
// PHONE LAYOUT (existing structure)
|
||||||
<>
|
<>
|
||||||
|
|
@ -3320,3 +2558,4 @@ const createStyles = (colors: any) => StyleSheet.create({
|
||||||
});
|
});
|
||||||
|
|
||||||
export default memo(StreamsScreen);
|
export default memo(StreamsScreen);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue