added contributors page

This commit is contained in:
tapframe 2025-10-24 02:14:50 +05:30
parent a7fbd567fd
commit 4daab74e27
4 changed files with 755 additions and 0 deletions

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

@ -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',
@ -652,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}
/>
@ -803,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
@ -888,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
@ -1209,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,

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