added contributors page
This commit is contained in:
parent
a7fbd567fd
commit
4daab74e27
4 changed files with 755 additions and 0 deletions
|
|
@ -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}
|
||||
|
|
|
|||
568
src/screens/ContributorsScreen.tsx
Normal file
568
src/screens/ContributorsScreen.tsx
Normal 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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue