Compare commits

..

14 commits
master ... main

Author SHA1 Message Date
tapframe
4daab74e27 added contributors page 2025-10-24 02:14:50 +05:30
tapframe
a7fbd567fd Added collections ection 2025-10-23 17:31:49 +05:30
tapframe
f90752bdb7 Streamscreen new changes update 2025-10-23 14:51:41 +05:30
tapframe
c5590639b1 revert 2025-10-23 13:44:08 +05:30
tapframe
098ab73ba1 anim changes ios ksplayer 2025-10-23 13:26:43 +05:30
tapframe
060b0b927b episode poster fix 2025-10-23 13:26:31 +05:30
tapframe
786e06b27f episodes not fetching backdrop fix tablet layout 2025-10-23 01:57:04 +05:30
tapframe
ef1c34a9c0 improved animations 2025-10-23 00:38:45 +05:30
tapframe
b97481f2d9 refactor streamscreen 2025-10-23 00:34:08 +05:30
tapframe
8d74b7e7ce changes 2025-10-22 23:55:45 +05:30
tapframe
635c97b1ad more improvements 2025-10-22 23:46:50 +05:30
tapframe
673c96c917 new streamscreen layout for tabs init 2025-10-22 23:36:37 +05:30
tapframe
15fc49d84d cleanup repo 2025-10-22 22:24:27 +05:30
tapframe
54cfd194f1 added yml for sponsoring 2025-10-22 22:22:18 +05:30
20 changed files with 3213 additions and 683 deletions

4
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,4 @@
# These are supported funding model platforms
github: [tapframe]
ko_fi: tapframe

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View file

@ -0,0 +1,795 @@
import React, { memo, useEffect, useState } 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';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withDelay,
Easing
} from 'react-native-reanimated';
// 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';
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]);
// Animation values for backdrop entrance
const backdropOpacity = useSharedValue(0);
const backdropScale = useSharedValue(1.05);
const [backdropLoaded, setBackdropLoaded] = useState(false);
const [backdropError, setBackdropError] = useState(false);
// Animation values for content panels
const leftPanelOpacity = useSharedValue(0);
const leftPanelTranslateX = useSharedValue(-30);
const rightPanelOpacity = useSharedValue(0);
const rightPanelTranslateX = useSharedValue(30);
// Get the backdrop source - prioritize episode thumbnail, then show backdrop, then poster
// For episodes without thumbnails, use show's backdrop instead of poster
const backdropSource = React.useMemo(() => {
// Debug logging
if (__DEV__) {
console.log('[TabletStreamsLayout] Backdrop source selection:', {
episodeImage,
bannerImage,
metadataPoster: metadata?.poster,
episodeImageIsPoster: episodeImage === metadata?.poster,
backdropError
});
}
// If episodeImage failed to load, skip it and use backdrop
if (backdropError && episodeImage && episodeImage !== metadata?.poster) {
if (__DEV__) console.log('[TabletStreamsLayout] Episode thumbnail failed, falling back to backdrop');
if (bannerImage) {
if (__DEV__) console.log('[TabletStreamsLayout] Using show backdrop (episode failed):', bannerImage);
return { uri: bannerImage };
}
}
// If episodeImage exists and is not the same as poster, use it (real episode thumbnail)
if (episodeImage && episodeImage !== metadata?.poster && !backdropError) {
if (__DEV__) console.log('[TabletStreamsLayout] Using episode thumbnail:', episodeImage);
return { uri: episodeImage };
}
// If episodeImage is the same as poster (fallback case), prioritize backdrop
if (bannerImage) {
if (__DEV__) console.log('[TabletStreamsLayout] Using show backdrop:', bannerImage);
return { uri: bannerImage };
}
// No fallback to poster images
if (__DEV__) console.log('[TabletStreamsLayout] No backdrop source found');
return undefined;
}, [episodeImage, bannerImage, metadata?.poster, backdropError]);
// Animate backdrop when it loads, or animate content immediately if no backdrop
useEffect(() => {
if (backdropSource?.uri && backdropLoaded) {
// Animate backdrop first
backdropOpacity.value = withTiming(1, {
duration: 800,
easing: Easing.out(Easing.cubic)
});
backdropScale.value = withTiming(1, {
duration: 1000,
easing: Easing.out(Easing.cubic)
});
// Animate content panels with delay after backdrop starts loading
leftPanelOpacity.value = withDelay(300, withTiming(1, {
duration: 600,
easing: Easing.out(Easing.cubic)
}));
leftPanelTranslateX.value = withDelay(300, withTiming(0, {
duration: 600,
easing: Easing.out(Easing.cubic)
}));
rightPanelOpacity.value = withDelay(500, withTiming(1, {
duration: 600,
easing: Easing.out(Easing.cubic)
}));
rightPanelTranslateX.value = withDelay(500, withTiming(0, {
duration: 600,
easing: Easing.out(Easing.cubic)
}));
} else if (!backdropSource?.uri) {
// No backdrop available, animate content panels immediately
leftPanelOpacity.value = withTiming(1, {
duration: 600,
easing: Easing.out(Easing.cubic)
});
leftPanelTranslateX.value = withTiming(0, {
duration: 600,
easing: Easing.out(Easing.cubic)
});
rightPanelOpacity.value = withDelay(200, withTiming(1, {
duration: 600,
easing: Easing.out(Easing.cubic)
}));
rightPanelTranslateX.value = withDelay(200, withTiming(0, {
duration: 600,
easing: Easing.out(Easing.cubic)
}));
}
}, [backdropSource?.uri, backdropLoaded]);
// Reset animation when episode changes
useEffect(() => {
backdropOpacity.value = 0;
backdropScale.value = 1.05;
leftPanelOpacity.value = 0;
leftPanelTranslateX.value = -30;
rightPanelOpacity.value = 0;
rightPanelTranslateX.value = 30;
setBackdropLoaded(false);
setBackdropError(false);
}, [episodeImage]);
// Animated styles for backdrop
const backdropAnimatedStyle = useAnimatedStyle(() => ({
opacity: backdropOpacity.value,
transform: [{ scale: backdropScale.value }],
}));
// Animated styles for content panels
const leftPanelAnimatedStyle = useAnimatedStyle(() => ({
opacity: leftPanelOpacity.value,
transform: [{ translateX: leftPanelTranslateX.value }],
}));
const rightPanelAnimatedStyle = useAnimatedStyle(() => ({
opacity: rightPanelOpacity.value,
transform: [{ translateX: rightPanelTranslateX.value }],
}));
const handleBackdropLoad = () => {
setBackdropLoaded(true);
};
const handleBackdropError = () => {
if (__DEV__) console.log('[TabletStreamsLayout] Backdrop image failed to load:', backdropSource?.uri);
setBackdropError(true);
setBackdropLoaded(false);
};
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: 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 || 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 with Entrance Animation */}
{backdropSource?.uri ? (
<Animated.View style={[styles.tabletFullScreenBackground, backdropAnimatedStyle]}>
<FastImage
source={backdropSource}
style={StyleSheet.absoluteFillObject}
resizeMode={FastImage.resizeMode.cover}
onLoad={handleBackdropLoad}
onError={handleBackdropError}
/>
</Animated.View>
) : (
<View style={styles.tabletFullScreenBackground}>
<View style={styles.tabletNoBackdropBackground} />
</View>
)}
<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 */}
<Animated.View style={[styles.tabletLeftPanel, leftPanelAnimatedStyle]}>
{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>
)}
</Animated.View>
{/* Right Panel: Streams List */}
<Animated.View style={[styles.tabletRightPanel, rightPanelAnimatedStyle]}>
{Platform.OS === 'android' && AndroidBlurView ? (
<View style={[
styles.streamsMainContent,
styles.tabletStreamsContent,
type === 'movie' && styles.streamsMainContentMovie
]}>
<AndroidBlurView
blurAmount={15}
blurRadius={8}
style={styles.androidBlurView}
>
<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>
</View>
) : (
<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>
)}
</Animated.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,
},
tabletNoBackdropBackground: {
...StyleSheet.absoluteFillObject,
backgroundColor: colors.darkBackground,
},
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',
},
androidBlurView: {
flex: 1,
backgroundColor: 'transparent',
},
});
export default memo(TabletStreamsLayout);

View file

@ -0,0 +1,234 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
ActivityIndicator,
Dimensions,
} from 'react-native';
import FastImage from '@d11/react-native-fast-image';
import { useNavigation, StackActions } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator';
import { StreamingContent } from '../../services/catalogService';
import { useTheme } from '../../contexts/ThemeContext';
import { TMDBService } from '../../services/tmdbService';
import { catalogService } from '../../services/catalogService';
import CustomAlert from '../../components/CustomAlert';
const { width } = Dimensions.get('window');
// Breakpoints for responsive sizing
const BREAKPOINTS = {
phone: 0,
tablet: 768,
largeTablet: 1024,
tv: 1440,
} as const;
interface CollectionSectionProps {
collectionName: string;
collectionMovies: StreamingContent[];
loadingCollection: boolean;
}
export const CollectionSection: React.FC<CollectionSectionProps> = ({
collectionName,
collectionMovies,
loadingCollection
}) => {
const { currentTheme } = useTheme();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
// Determine device type
const deviceWidth = Dimensions.get('window').width;
const getDeviceType = React.useCallback(() => {
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
}, [deviceWidth]);
const deviceType = getDeviceType();
const isTablet = deviceType === 'tablet';
const isLargeTablet = deviceType === 'largeTablet';
const isTV = deviceType === 'tv';
// Responsive spacing & sizes
const horizontalPadding = React.useMemo(() => {
switch (deviceType) {
case 'tv': return 32;
case 'largeTablet': return 28;
case 'tablet': return 24;
default: return 16;
}
}, [deviceType]);
const itemSpacing = React.useMemo(() => {
switch (deviceType) {
case 'tv': return 14;
case 'largeTablet': return 12;
case 'tablet': return 12;
default: return 12;
}
}, [deviceType]);
const backdropWidth = React.useMemo(() => {
switch (deviceType) {
case 'tv': return 240;
case 'largeTablet': return 220;
case 'tablet': return 200;
default: return 180;
}
}, [deviceType]);
const backdropHeight = React.useMemo(() => backdropWidth * (9/16), [backdropWidth]); // 16:9 aspect ratio
const [alertVisible, setAlertVisible] = React.useState(false);
const [alertTitle, setAlertTitle] = React.useState('');
const [alertMessage, setAlertMessage] = React.useState('');
const [alertActions, setAlertActions] = React.useState<any[]>([]);
const handleItemPress = async (item: StreamingContent) => {
try {
// Extract TMDB ID from the tmdb:123456 format
const tmdbId = item.id.replace('tmdb:', '');
// Get Stremio ID directly using catalogService
const stremioId = await catalogService.getStremioId(item.type, tmdbId);
if (stremioId) {
navigation.dispatch(
StackActions.push('Metadata', {
id: stremioId,
type: item.type
})
);
} else {
throw new Error('Could not find Stremio ID');
}
} catch (error) {
if (__DEV__) console.error('Error navigating to collection item:', error);
setAlertTitle('Error');
setAlertMessage('Unable to load this content. Please try again later.');
setAlertActions([{ label: 'OK', onPress: () => {} }]);
setAlertVisible(true);
}
};
const renderItem = ({ item }: { item: StreamingContent }) => (
<TouchableOpacity
style={[styles.itemContainer, { width: backdropWidth, marginRight: itemSpacing }]}
onPress={() => handleItemPress(item)}
>
<FastImage
source={{ uri: item.banner || item.poster }}
style={[styles.backdrop, {
backgroundColor: currentTheme.colors.elevation1,
width: backdropWidth,
height: backdropHeight,
borderRadius: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 10 : 8
}]}
resizeMode={FastImage.resizeMode.cover}
/>
<Text style={[styles.title, {
color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 13,
lineHeight: isTV ? 20 : 18
}]} numberOfLines={2}>
{item.name}
</Text>
{item.year && (
<Text style={[styles.year, {
color: currentTheme.colors.textMuted,
fontSize: isTV ? 12 : isLargeTablet ? 11 : isTablet ? 11 : 11
}]}>
{item.year}
</Text>
)}
</TouchableOpacity>
);
if (loadingCollection) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
</View>
);
}
if (!collectionMovies || collectionMovies.length === 0) {
return null; // Don't render anything if there are no collection movies
}
return (
<View style={[styles.container, { paddingLeft: 0 }] }>
<Text style={[styles.sectionTitle, {
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20,
paddingHorizontal: horizontalPadding
}]}>
{collectionName}
</Text>
<FlatList
data={collectionMovies}
renderItem={renderItem}
keyExtractor={(item) => item.id}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={[styles.listContentContainer, {
paddingHorizontal: horizontalPadding,
paddingRight: horizontalPadding + itemSpacing
}]}
/>
<CustomAlert
visible={alertVisible}
title={alertTitle}
message={alertMessage}
actions={alertActions}
onClose={() => setAlertVisible(false)}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginTop: 16,
marginBottom: 16,
},
sectionTitle: {
fontSize: 20,
fontWeight: '800',
marginBottom: 12,
marginTop: 8,
},
listContentContainer: {
paddingRight: 32, // Will be overridden responsively
},
itemContainer: {
marginRight: 12, // will be overridden responsively
},
backdrop: {
borderRadius: 8, // overridden responsively
marginBottom: 8,
},
title: {
fontSize: 13, // overridden responsively
fontWeight: '500',
lineHeight: 18, // overridden responsively
marginBottom: 2,
},
year: {
fontSize: 11, // overridden responsively
fontWeight: '400',
opacity: 0.8,
},
loadingContainer: {
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 20,
},
});
export default CollectionSection;

View file

@ -107,6 +107,8 @@ interface UseMetadataReturn {
imdbId: string | null;
scraperStatuses: ScraperStatus[];
activeFetchingScrapers: string[];
collectionMovies: StreamingContent[];
loadingCollection: boolean;
}
export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadataReturn => {
@ -132,6 +134,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const [loadAttempts, setLoadAttempts] = useState(0);
const [recommendations, setRecommendations] = useState<StreamingContent[]>([]);
const [loadingRecommendations, setLoadingRecommendations] = useState(false);
const [collectionMovies, setCollectionMovies] = useState<StreamingContent[]>([]);
const [loadingCollection, setLoadingCollection] = useState(false);
const [imdbId, setImdbId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [availableStreams, setAvailableStreams] = useState<{ [sourceType: string]: Stream }>({});
@ -875,6 +879,13 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
}
// Commit final metadata once and cache it
// Clear banner field if TMDB enrichment is enabled to prevent flash
if (settings.enrichMetadataWithTMDB) {
finalMetadata = {
...finalMetadata,
banner: undefined, // Let useMetadataAssets handle banner via TMDB
};
}
setMetadata(finalMetadata);
cacheService.setMetadata(id, type, finalMetadata);
const isInLib = catalogService.getLibraryItems().some(item => item.id === id);
@ -1932,6 +1943,94 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
tmdbId,
movieDetails: movieDetailsObj
}));
// Fetch collection data if movie belongs to a collection
if (movieDetails.belongs_to_collection) {
setLoadingCollection(true);
try {
const collectionDetails = await tmdbService.getCollectionDetails(
movieDetails.belongs_to_collection.id,
lang
);
if (collectionDetails && collectionDetails.parts) {
// Fetch individual movie images to get backdrops with embedded titles/logos
const collectionMoviesData = await Promise.all(
collectionDetails.parts.map(async (part: any, index: number) => {
let movieBackdropUrl = undefined;
// Try to fetch movie images with language parameter
try {
const movieImages = await tmdbService.getMovieImagesFull(part.id);
if (movieImages && movieImages.backdrops && movieImages.backdrops.length > 0) {
// Filter and sort backdrops by language and quality
const languageBackdrops = movieImages.backdrops
.filter((backdrop: any) => backdrop.aspect_ratio > 1.0) // Landscape orientation
.sort((a: any, b: any) => {
// Prioritize backdrops with the requested language
const aHasLang = a.iso_639_1 === lang;
const bHasLang = b.iso_639_1 === lang;
if (aHasLang && !bHasLang) return -1;
if (!aHasLang && bHasLang) return 1;
// Then prioritize English if requested language not available
const aIsEn = a.iso_639_1 === 'en';
const bIsEn = b.iso_639_1 === 'en';
if (aIsEn && !bIsEn) return -1;
if (!aIsEn && bIsEn) return 1;
// Then sort by vote average (quality), then by resolution
if (a.vote_average !== b.vote_average) {
return b.vote_average - a.vote_average;
}
return (b.width * b.height) - (a.width * a.height);
});
if (languageBackdrops.length > 0) {
movieBackdropUrl = tmdbService.getImageUrl(languageBackdrops[0].file_path, 'original');
}
}
} catch (error) {
if (__DEV__) console.warn('[useMetadata] Failed to fetch movie images for:', part.id, error);
}
return {
id: `tmdb:${part.id}`,
type: 'movie',
name: part.title,
poster: part.poster_path ? tmdbService.getImageUrl(part.poster_path, 'w500') : 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image',
banner: movieBackdropUrl || (part.backdrop_path ? tmdbService.getImageUrl(part.backdrop_path, 'original') : undefined),
year: part.release_date ? new Date(part.release_date).getFullYear() : undefined,
description: part.overview,
collection: {
id: collectionDetails.id,
name: collectionDetails.name,
poster_path: collectionDetails.poster_path,
backdrop_path: collectionDetails.backdrop_path
}
};
})
) as StreamingContent[];
setCollectionMovies(collectionMoviesData);
// Update metadata with collection info
setMetadata((prev: any) => ({
...prev,
collection: {
id: collectionDetails.id,
name: collectionDetails.name,
poster_path: collectionDetails.poster_path,
backdrop_path: collectionDetails.backdrop_path
}
}));
}
} catch (error) {
if (__DEV__) console.error('[useMetadata] Error fetching collection:', error);
} finally {
setLoadingCollection(false);
}
}
}
}
@ -2017,5 +2116,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
imdbId,
scraperStatuses,
activeFetchingScrapers,
collectionMovies,
loadingCollection,
};
};

View file

@ -251,16 +251,10 @@ export const useMetadataAssets = (
setLoadingBanner(true);
// Show fallback banner immediately to prevent blank state
const fallbackBanner = metadata?.banner || metadata?.poster || null;
if (fallbackBanner && !bannerImage) {
setBannerImage(fallbackBanner);
setBannerSource('default');
}
// If enrichment is disabled, use addon banner and don't fetch from external sources
if (!settings.enrichMetadataWithTMDB) {
const addonBanner = metadata?.banner || metadata?.poster || null;
const addonBanner = metadata?.banner || null;
if (addonBanner && addonBanner !== bannerImage) {
setBannerImage(addonBanner);
setBannerSource('default');
@ -312,15 +306,6 @@ export const useMetadataAssets = (
finalBanner = tmdbService.getImageUrl(details.backdrop_path);
bannerSourceType = 'tmdb';
// Preload the image
if (finalBanner) {
FastImage.preload([{ uri: finalBanner }]);
}
}
else if (details?.poster_path) {
finalBanner = tmdbService.getImageUrl(details.poster_path);
bannerSourceType = 'tmdb';
// Preload the image
if (finalBanner) {
FastImage.preload([{ uri: finalBanner }]);
@ -332,9 +317,9 @@ export const useMetadataAssets = (
}
}
// Final fallback to metadata
// Final fallback to metadata banner only
if (!finalBanner) {
finalBanner = metadata?.banner || metadata?.poster || null;
finalBanner = metadata?.banner || null;
bannerSourceType = 'default';
}
@ -346,8 +331,8 @@ export const useMetadataAssets = (
forcedBannerRefreshDone.current = true;
} catch (error) {
// Use default banner on error
const defaultBanner = metadata?.banner || metadata?.poster || null;
// Use default banner on error (only addon banner)
const defaultBanner = metadata?.banner || null;
if (defaultBanner !== bannerImage) {
setBannerImage(defaultBanner);
setBannerSource('default');

View file

@ -86,6 +86,7 @@ export interface AppSettings {
useCachedStreams: boolean; // Enable/disable direct player navigation from Continue Watching cache
openMetadataScreenWhenCacheDisabled: boolean; // When cache disabled, open MetadataScreen instead of StreamsScreen
streamCacheTTL: number; // Stream cache duration in milliseconds (default: 1 hour)
enableStreamsBackdrop: boolean; // Enable blurred backdrop background on StreamsScreen mobile
}
export const DEFAULT_SETTINGS: AppSettings = {
@ -145,6 +146,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
useCachedStreams: false, // Enable by default
openMetadataScreenWhenCacheDisabled: true, // Default to StreamsScreen when cache disabled
streamCacheTTL: 60 * 60 * 1000, // Default: 1 hour in milliseconds
enableStreamsBackdrop: true, // Enable by default (new behavior)
};
const SETTINGS_STORAGE_KEY = 'app_settings';

View file

@ -68,6 +68,7 @@ import AIChatScreen from '../screens/AIChatScreen';
import BackdropGalleryScreen from '../screens/BackdropGalleryScreen';
import BackupScreen from '../screens/BackupScreen';
import ContinueWatchingSettingsScreen from '../screens/ContinueWatchingSettingsScreen';
import ContributorsScreen from '../screens/ContributorsScreen';
// Stack navigator types
export type RootStackParamList = {
@ -175,6 +176,7 @@ export type RootStackParamList = {
title: string;
};
ContinueWatchingSettings: undefined;
Contributors: undefined;
};
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
@ -1302,6 +1304,21 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
},
}}
/>
<Stack.Screen
name="Contributors"
component={ContributorsScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'default',
animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="HeroCatalogs"
component={HeroCatalogsScreen}

View file

@ -0,0 +1,568 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ScrollView,
SafeAreaView,
StatusBar,
Platform,
Dimensions,
Linking,
RefreshControl,
FlatList,
ActivityIndicator
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import FastImage from '@d11/react-native-fast-image';
import { Feather } from '@expo/vector-icons';
import { useTheme } from '../contexts/ThemeContext';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { fetchContributors, GitHubContributor } from '../services/githubReleaseService';
import { RootStackParamList } from '../navigation/AppNavigator';
const { width, height } = Dimensions.get('window');
const isTablet = width >= 768;
const isLargeTablet = width >= 1024;
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
interface ContributorCardProps {
contributor: GitHubContributor;
currentTheme: any;
isTablet: boolean;
isLargeTablet: boolean;
}
const ContributorCard: React.FC<ContributorCardProps> = ({ contributor, currentTheme, isTablet, isLargeTablet }) => {
const handlePress = useCallback(() => {
Linking.openURL(contributor.html_url);
}, [contributor.html_url]);
return (
<TouchableOpacity
style={[
styles.contributorCard,
{ backgroundColor: currentTheme.colors.elevation1 },
isTablet && styles.tabletContributorCard
]}
onPress={handlePress}
activeOpacity={0.7}
>
<FastImage
source={{ uri: contributor.avatar_url }}
style={[
styles.avatar,
isTablet && styles.tabletAvatar
]}
resizeMode={FastImage.resizeMode.cover}
/>
<View style={styles.contributorInfo}>
<Text style={[
styles.username,
{ color: currentTheme.colors.highEmphasis },
isTablet && styles.tabletUsername
]}>
{contributor.login}
</Text>
<Text style={[
styles.contributions,
{ color: currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletContributions
]}>
{contributor.contributions} contributions
</Text>
</View>
<Feather
name="external-link"
size={isTablet ? 20 : 16}
color={currentTheme.colors.mediumEmphasis}
style={styles.externalIcon}
/>
</TouchableOpacity>
);
};
const ContributorsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
const [contributors, setContributors] = useState<GitHubContributor[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadContributors = useCallback(async (isRefresh = false) => {
try {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
// Check cache first (unless refreshing)
if (!isRefresh) {
try {
const cachedData = await AsyncStorage.getItem('github_contributors');
const cacheTimestamp = await AsyncStorage.getItem('github_contributors_timestamp');
const now = Date.now();
const ONE_HOUR = 60 * 60 * 1000; // 1 hour cache
if (cachedData && cacheTimestamp) {
const timestamp = parseInt(cacheTimestamp, 10);
if (now - timestamp < ONE_HOUR) {
const parsedData = JSON.parse(cachedData);
// Only use cache if it has actual contributors data
if (parsedData && Array.isArray(parsedData) && parsedData.length > 0) {
setContributors(parsedData);
setLoading(false);
return;
} else {
// Remove invalid cache
await AsyncStorage.removeItem('github_contributors');
await AsyncStorage.removeItem('github_contributors_timestamp');
if (__DEV__) console.log('Removed invalid contributors cache');
}
}
}
} catch (cacheError) {
if (__DEV__) console.error('Cache read error:', cacheError);
// Remove corrupted cache
try {
await AsyncStorage.removeItem('github_contributors');
await AsyncStorage.removeItem('github_contributors_timestamp');
} catch {}
}
}
const data = await fetchContributors();
if (data && Array.isArray(data) && data.length > 0) {
setContributors(data);
// Only cache valid data
try {
await AsyncStorage.setItem('github_contributors', JSON.stringify(data));
await AsyncStorage.setItem('github_contributors_timestamp', Date.now().toString());
} catch (cacheError) {
if (__DEV__) console.error('Cache write error:', cacheError);
}
} else {
// Clear any existing cache if we get invalid data
try {
await AsyncStorage.removeItem('github_contributors');
await AsyncStorage.removeItem('github_contributors_timestamp');
} catch {}
setError('Unable to load contributors. This might be due to GitHub API rate limits.');
}
} catch (err) {
setError('Failed to load contributors. Please check your internet connection.');
if (__DEV__) console.error('Error loading contributors:', err);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
// Clear any invalid cache on mount
const clearInvalidCache = async () => {
try {
const cachedData = await AsyncStorage.getItem('github_contributors');
if (cachedData) {
const parsedData = JSON.parse(cachedData);
if (!parsedData || !Array.isArray(parsedData) || parsedData.length === 0) {
await AsyncStorage.removeItem('github_contributors');
await AsyncStorage.removeItem('github_contributors_timestamp');
if (__DEV__) console.log('Cleared invalid cache on mount');
}
}
} catch (error) {
if (__DEV__) console.error('Error checking cache on mount:', error);
}
};
clearInvalidCache();
loadContributors();
}, [loadContributors]);
const handleRefresh = useCallback(() => {
loadContributors(true);
}, [loadContributors]);
const renderContributor = useCallback(({ item }: { item: GitHubContributor }) => (
<ContributorCard
contributor={item}
currentTheme={currentTheme}
isTablet={isTablet}
isLargeTablet={isLargeTablet}
/>
), [currentTheme]);
const keyExtractor = useCallback((item: GitHubContributor) => item.id.toString(), []);
const topSpacing = (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top);
if (loading && !refreshing) {
return (
<View style={[
styles.container,
{ backgroundColor: currentTheme.colors.darkBackground }
]}>
<StatusBar barStyle={'light-content'} />
<View style={[styles.headerContainer, { paddingTop: topSpacing }]}>
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<Feather name="chevron-left" size={24} color={currentTheme.colors.primary} />
<Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text>
</TouchableOpacity>
</View>
<Text style={[
styles.headerTitle,
{ color: currentTheme.colors.text },
isTablet && styles.tabletHeaderTitle
]}>
Contributors
</Text>
</View>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={[styles.loadingText, { color: currentTheme.colors.mediumEmphasis }]}>
Loading contributors...
</Text>
</View>
</View>
);
}
return (
<View style={[
styles.container,
{ backgroundColor: currentTheme.colors.darkBackground }
]}>
<StatusBar barStyle={'light-content'} />
<View style={[styles.headerContainer, { paddingTop: topSpacing }]}>
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<Feather name="chevron-left" size={24} color={currentTheme.colors.primary} />
<Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text>
</TouchableOpacity>
</View>
<Text style={[
styles.headerTitle,
{ color: currentTheme.colors.text },
isTablet && styles.tabletHeaderTitle
]}>
Contributors
</Text>
</View>
<View style={styles.content}>
<View style={[styles.contentContainer, isTablet && styles.tabletContentContainer]}>
{error ? (
<View style={styles.errorContainer}>
<Feather name="alert-circle" size={48} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.errorText, { color: currentTheme.colors.mediumEmphasis }]}>
{error}
</Text>
<Text style={[styles.errorSubtext, { color: currentTheme.colors.mediumEmphasis }]}>
GitHub API rate limit exceeded. Please try again later or pull to refresh.
</Text>
<TouchableOpacity
style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => loadContributors()}
>
<Text style={[styles.retryText, { color: currentTheme.colors.white }]}>
Try Again
</Text>
</TouchableOpacity>
</View>
) : contributors.length === 0 ? (
<View style={styles.emptyContainer}>
<Feather name="users" size={48} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.emptyText, { color: currentTheme.colors.mediumEmphasis }]}>
No contributors found
</Text>
</View>
) : (
<ScrollView
style={styles.scrollView}
contentContainerStyle={[
styles.listContent,
isTablet && styles.tabletListContent
]}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={currentTheme.colors.primary}
colors={[currentTheme.colors.primary]}
/>
}
showsVerticalScrollIndicator={false}
>
<View style={[
styles.gratitudeCard,
{ backgroundColor: currentTheme.colors.elevation1 },
isTablet && styles.tabletGratitudeCard
]}>
<View style={styles.gratitudeContent}>
<Feather name="heart" size={isTablet ? 32 : 24} color={currentTheme.colors.primary} />
<Text style={[
styles.gratitudeText,
{ color: currentTheme.colors.highEmphasis },
isTablet && styles.tabletGratitudeText
]}>
We're grateful for every contribution
</Text>
<Text style={[
styles.gratitudeSubtext,
{ color: currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletGratitudeSubtext
]}>
Each line of code, bug report, and suggestion helps make Nuvio better for everyone
</Text>
</View>
</View>
<FlatList
data={contributors}
renderItem={renderContributor}
keyExtractor={keyExtractor}
numColumns={isTablet ? 2 : 1}
key={isTablet ? 'tablet' : 'mobile'}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
columnWrapperStyle={isTablet ? styles.tabletRow : undefined}
/>
</ScrollView>
)}
</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
headerContainer: {
paddingHorizontal: 20,
paddingBottom: 8,
backgroundColor: 'transparent',
zIndex: 2,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
},
backText: {
fontSize: 16,
fontWeight: '500',
marginLeft: 4,
},
headerTitle: {
fontSize: 32,
fontWeight: '800',
letterSpacing: 0.3,
paddingLeft: 4,
},
tabletHeaderTitle: {
fontSize: 40,
letterSpacing: 0.5,
},
content: {
flex: 1,
zIndex: 1,
alignItems: 'center',
},
contentContainer: {
flex: 1,
width: '100%',
},
tabletContentContainer: {
maxWidth: 1000,
width: '100%',
},
scrollView: {
flex: 1,
},
gratitudeCard: {
padding: 20,
marginBottom: 20,
borderRadius: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
tabletGratitudeCard: {
padding: 32,
marginBottom: 32,
borderRadius: 24,
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 5,
},
gratitudeContent: {
alignItems: 'center',
},
gratitudeText: {
fontSize: 18,
fontWeight: '600',
marginTop: 12,
marginBottom: 8,
textAlign: 'center',
},
tabletGratitudeText: {
fontSize: 24,
fontWeight: '700',
marginTop: 16,
marginBottom: 12,
},
gratitudeSubtext: {
fontSize: 14,
lineHeight: 20,
opacity: 0.8,
textAlign: 'center',
},
tabletGratitudeSubtext: {
fontSize: 17,
lineHeight: 26,
maxWidth: 600,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 12,
fontSize: 16,
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 40,
},
errorText: {
fontSize: 16,
textAlign: 'center',
marginTop: 16,
marginBottom: 8,
},
errorSubtext: {
fontSize: 14,
textAlign: 'center',
opacity: 0.7,
marginBottom: 24,
},
retryButton: {
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
},
retryText: {
fontSize: 16,
fontWeight: '600',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 40,
},
emptyText: {
fontSize: 16,
textAlign: 'center',
marginTop: 16,
},
listContent: {
paddingHorizontal: 16,
paddingBottom: 20,
},
tabletListContent: {
paddingHorizontal: 32,
paddingBottom: 40,
},
tabletRow: {
justifyContent: 'space-between',
},
contributorCard: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
marginBottom: 12,
borderRadius: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
tabletContributorCard: {
padding: 20,
marginBottom: 16,
marginHorizontal: 6,
borderRadius: 20,
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 5,
width: '48%',
},
avatar: {
width: 60,
height: 60,
borderRadius: 30,
marginRight: 16,
},
tabletAvatar: {
width: 80,
height: 80,
borderRadius: 40,
marginRight: 20,
},
contributorInfo: {
flex: 1,
},
username: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
tabletUsername: {
fontSize: 18,
fontWeight: '700',
},
contributions: {
fontSize: 14,
opacity: 0.8,
},
tabletContributions: {
fontSize: 16,
},
externalIcon: {
marginLeft: 8,
},
});
export default ContributorsScreen;

View file

@ -28,6 +28,7 @@ import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection'
import { RatingsSection } from '../components/metadata/RatingsSection';
import { CommentsSection, CommentBottomSheet } from '../components/metadata/CommentsSection';
import TrailersSection from '../components/metadata/TrailersSection';
import CollectionSection from '../components/metadata/CollectionSection';
import { RouteParams, Episode } from '../types/metadata';
import Animated, {
useAnimatedStyle,
@ -182,6 +183,8 @@ const MetadataScreen: React.FC = () => {
setMetadata,
imdbId,
tmdbId,
collectionMovies,
loadingCollection,
} = useMetadata({ id, type, addonId });
@ -1245,6 +1248,18 @@ const MetadataScreen: React.FC = () => {
</View>
)}
{/* Collection Section - Lazy loaded */}
{shouldLoadSecondaryData &&
Object.keys(groupedEpisodes).length === 0 &&
metadata?.collection &&
settings.enrichMetadataWithTMDB && (
<CollectionSection
collectionName={metadata.collection.name}
collectionMovies={collectionMovies}
loadingCollection={loadingCollection}
/>
)}
{/* Recommendations Section with skeleton when loading - Lazy loaded */}
{type === 'movie' && shouldLoadSecondaryData && (
<MemoizedMoreLikeThisSection

View file

@ -27,6 +27,7 @@ import { useCatalogContext } from '../contexts/CatalogContext';
import { useTraktContext } from '../contexts/TraktContext';
import { useTheme } from '../contexts/ThemeContext';
import { catalogService } from '../services/catalogService';
import { fetchTotalDownloads } from '../services/githubReleaseService';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as Sentry from '@sentry/react-native';
import { getDisplayedAppVersion } from '../utils/version';
@ -291,6 +292,9 @@ const SettingsScreen: React.FC = () => {
const [mdblistKeySet, setMdblistKeySet] = useState<boolean>(false);
const [openRouterKeySet, setOpenRouterKeySet] = useState<boolean>(false);
const [initialLoadComplete, setInitialLoadComplete] = useState<boolean>(false);
const [totalDownloads, setTotalDownloads] = useState<number | null>(null);
const [displayDownloads, setDisplayDownloads] = useState<number | null>(null);
const [isCountingUp, setIsCountingUp] = useState<boolean>(false);
// Add a useEffect to check Trakt authentication status on focus
useEffect(() => {
@ -346,6 +350,13 @@ const SettingsScreen: React.FC = () => {
// Check OpenRouter API key status
const openRouterKey = await AsyncStorage.getItem('openrouter_api_key');
setOpenRouterKeySet(!!openRouterKey);
// Load GitHub total downloads (initial load only, polling happens in useEffect)
const downloads = await fetchTotalDownloads();
if (downloads !== null) {
setTotalDownloads(downloads);
setDisplayDownloads(downloads);
}
} catch (error) {
if (__DEV__) console.error('Error loading settings data:', error);
@ -366,6 +377,60 @@ const SettingsScreen: React.FC = () => {
return unsubscribe;
}, [navigation, loadData]);
// Poll GitHub downloads every 10 seconds when on the About section
useEffect(() => {
// Only poll when viewing the About section (where downloads counter is shown)
const shouldPoll = isTablet ? selectedCategory === 'about' : true;
if (!shouldPoll) return;
const pollInterval = setInterval(async () => {
try {
const downloads = await fetchTotalDownloads();
if (downloads !== null && downloads !== totalDownloads) {
setTotalDownloads(downloads);
}
} catch (error) {
if (__DEV__) console.error('Error polling downloads:', error);
}
}, 3600000); // 3600000 milliseconds (1 hour)
return () => clearInterval(pollInterval);
}, [selectedCategory, isTablet, totalDownloads]);
// Animate counting up when totalDownloads changes
useEffect(() => {
if (totalDownloads === null || displayDownloads === null) return;
if (totalDownloads === displayDownloads) return;
setIsCountingUp(true);
const start = displayDownloads;
const end = totalDownloads;
const duration = 2000; // 2 seconds animation
const startTime = Date.now();
const animate = () => {
const now = Date.now();
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
// Ease out quad for smooth deceleration
const easeProgress = 1 - Math.pow(1 - progress, 2);
const current = Math.floor(start + (end - start) * easeProgress);
setDisplayDownloads(current);
if (progress < 1) {
requestAnimationFrame(animate);
} else {
setDisplayDownloads(end);
setIsCountingUp(false);
}
};
requestAnimationFrame(animate);
}, [totalDownloads]);
const handleResetSettings = useCallback(() => {
openAlert(
'Reset Settings',
@ -518,9 +583,24 @@ const SettingsScreen: React.FC = () => {
onValueChange={(value) => updateSetting('episodeLayoutStyle', value ? 'horizontal' : 'vertical')}
/>
)}
isLast={true}
isLast={isTablet}
isTablet={isTablet}
/>
{!isTablet && (
<SettingItem
title="Streams Backdrop"
description="Show blurred backdrop on mobile streams"
icon="image"
renderControl={() => (
<CustomSwitch
value={settings?.enableStreamsBackdrop ?? true}
onValueChange={(value) => updateSetting('enableStreamsBackdrop', value)}
/>
)}
isLast={true}
isTablet={isTablet}
/>
)}
</SettingsCard>
);
@ -637,6 +717,14 @@ const SettingsScreen: React.FC = () => {
title="Version"
description={getDisplayedAppVersion()}
icon="info"
isTablet={isTablet}
/>
<SettingItem
title="Contributors"
description="View all contributors"
icon="users"
renderControl={ChevronRight}
onPress={() => navigation.navigate('Contributors')}
isLast={true}
isTablet={isTablet}
/>
@ -788,6 +876,17 @@ const SettingsScreen: React.FC = () => {
{selectedCategory === 'about' && (
<>
{displayDownloads !== null && (
<View style={styles.downloadsContainer}>
<Text style={[styles.downloadsNumber, { color: currentTheme.colors.primary }]}>
{displayDownloads.toLocaleString()}
</Text>
<Text style={[styles.downloadsLabel, { color: currentTheme.colors.mediumEmphasis }]}>
downloads and counting
</Text>
</View>
)}
<View style={styles.footer}>
<Text style={[styles.footerText, { color: currentTheme.colors.mediumEmphasis }]}>
Made with by Tapframe and Friends
@ -873,6 +972,17 @@ const SettingsScreen: React.FC = () => {
{renderCategoryContent('developer')}
{renderCategoryContent('cache')}
{displayDownloads !== null && (
<View style={styles.downloadsContainer}>
<Text style={[styles.downloadsNumber, { color: currentTheme.colors.primary }]}>
{displayDownloads.toLocaleString()}
</Text>
<Text style={[styles.downloadsLabel, { color: currentTheme.colors.mediumEmphasis }]}>
downloads and counting
</Text>
</View>
)}
<View style={styles.footer}>
<Text style={[styles.footerText, { color: currentTheme.colors.mediumEmphasis }]}>
Made with by Tapframe and friends
@ -1194,6 +1304,24 @@ const styles = StyleSheet.create({
height: 32,
width: 150,
},
downloadsContainer: {
marginTop: 20,
marginBottom: 12,
alignItems: 'center',
},
downloadsNumber: {
fontSize: 32,
fontWeight: '800',
letterSpacing: 1,
marginBottom: 4,
},
downloadsLabel: {
fontSize: 11,
fontWeight: '600',
opacity: 0.6,
letterSpacing: 1.2,
textTransform: 'uppercase',
},
loadingSpinner: {
width: 16,
height: 16,

File diff suppressed because it is too large Load diff

View file

@ -125,6 +125,12 @@ export interface StreamingContent {
originCountry?: string[];
tagline?: string;
};
collection?: {
id: number;
name: string;
poster_path?: string;
backdrop_path?: string;
};
}
export interface CatalogContent {

View file

@ -59,4 +59,61 @@ export function isAnyUpgrade(current: string, latest: string): boolean {
return b[2] > a[2];
}
export async function fetchTotalDownloads(): Promise<number | null> {
try {
const res = await fetch('https://api.github.com/repos/tapframe/NuvioStreaming/releases', {
headers: {
'Accept': 'application/vnd.github+json',
'User-Agent': `Nuvio/${Platform.OS}`,
},
});
if (!res.ok) return null;
const releases = await res.json();
let total = 0;
releases.forEach((release: any) => {
if (release.assets && Array.isArray(release.assets)) {
release.assets.forEach((asset: any) => {
total += asset.download_count || 0;
});
}
});
return total;
} catch {
return null;
}
}
export interface GitHubContributor {
login: string;
id: number;
avatar_url: string;
html_url: string;
contributions: number;
type: string;
}
export async function fetchContributors(): Promise<GitHubContributor[] | null> {
try {
const res = await fetch('https://api.github.com/repos/tapframe/NuvioStreaming/contributors', {
headers: {
'Accept': 'application/vnd.github+json',
'User-Agent': `Nuvio/${Platform.OS}`,
},
});
if (!res.ok) {
if (__DEV__) console.error('GitHub API error:', res.status, res.statusText);
return null;
}
const contributors = await res.json();
return contributors;
} catch (error) {
if (__DEV__) console.error('Error fetching contributors:', error);
return null;
}
}

View file

@ -77,6 +77,32 @@ export interface TMDBTrendingResult {
};
}
export interface TMDBCollection {
id: number;
name: string;
overview: string;
poster_path: string | null;
backdrop_path: string | null;
parts: TMDBCollectionPart[];
}
export interface TMDBCollectionPart {
id: number;
title: string;
overview: string;
poster_path: string | null;
backdrop_path: string | null;
release_date: string;
adult: boolean;
video: boolean;
vote_average: number;
vote_count: number;
genre_ids: number[];
original_language: string;
original_title: string;
popularity: number;
}
export class TMDBService {
private static instance: TMDBService;
private static ratingCache: Map<string, number | null> = new Map();
@ -604,6 +630,41 @@ export class TMDBService {
}
}
/**
* Get collection details by collection ID
*/
async getCollectionDetails(collectionId: number, language: string = 'en'): Promise<TMDBCollection | null> {
try {
const response = await axios.get(`${BASE_URL}/collection/${collectionId}`, {
headers: await this.getHeaders(),
params: await this.getParams({
language,
}),
});
return response.data;
} catch (error) {
return null;
}
}
/**
* Get collection images by collection ID
*/
async getCollectionImages(collectionId: number, language: string = 'en'): Promise<any> {
try {
const response = await axios.get(`${BASE_URL}/collection/${collectionId}/images`, {
headers: await this.getHeaders(),
params: await this.getParams({
language,
include_image_language: `${language},en,null`
}),
});
return response.data;
} catch (error) {
return null;
}
}
/**
* Get movie images (logos, posters, backdrops) by TMDB ID - returns full images object
*/