From 4daab74e27cecea18b602c59273b6006142685ef Mon Sep 17 00:00:00 2001 From: tapframe Date: Fri, 24 Oct 2025 02:14:50 +0530 Subject: [PATCH] added contributors page --- src/navigation/AppNavigator.tsx | 17 + src/screens/ContributorsScreen.tsx | 568 +++++++++++++++++++++++++++ src/screens/SettingsScreen.tsx | 113 ++++++ src/services/githubReleaseService.ts | 57 +++ 4 files changed, 755 insertions(+) create mode 100644 src/screens/ContributorsScreen.tsx diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 365504a..58794fa 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -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; @@ -1302,6 +1304,21 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta }, }} /> + = 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 = ({ contributor, currentTheme, isTablet, isLargeTablet }) => { + const handlePress = useCallback(() => { + Linking.openURL(contributor.html_url); + }, [contributor.html_url]); + + return ( + + + + + {contributor.login} + + + {contributor.contributions} contributions + + + + + ); +}; + +const ContributorsScreen: React.FC = () => { + const navigation = useNavigation>(); + const { currentTheme } = useTheme(); + const insets = useSafeAreaInsets(); + + const [contributors, setContributors] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(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 }) => ( + + ), [currentTheme]); + + const keyExtractor = useCallback((item: GitHubContributor) => item.id.toString(), []); + + const topSpacing = (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top); + + if (loading && !refreshing) { + return ( + + + + + navigation.goBack()} + > + + Settings + + + + Contributors + + + + + + Loading contributors... + + + + ); + } + + return ( + + + + + + navigation.goBack()} + > + + Settings + + + + Contributors + + + + + + {error ? ( + + + + {error} + + + GitHub API rate limit exceeded. Please try again later or pull to refresh. + + loadContributors()} + > + + Try Again + + + + ) : contributors.length === 0 ? ( + + + + No contributors found + + + ) : ( + + } + showsVerticalScrollIndicator={false} + > + + + + + We're grateful for every contribution + + + Each line of code, bug report, and suggestion helps make Nuvio better for everyone + + + + + + + )} + + + + ); +}; + +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; diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index dd59dfc..f0771d6 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -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(false); const [openRouterKeySet, setOpenRouterKeySet] = useState(false); const [initialLoadComplete, setInitialLoadComplete] = useState(false); + const [totalDownloads, setTotalDownloads] = useState(null); + const [displayDownloads, setDisplayDownloads] = useState(null); + const [isCountingUp, setIsCountingUp] = useState(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} + /> + navigation.navigate('Contributors')} isLast={true} isTablet={isTablet} /> @@ -803,6 +876,17 @@ const SettingsScreen: React.FC = () => { {selectedCategory === 'about' && ( <> + {displayDownloads !== null && ( + + + {displayDownloads.toLocaleString()} + + + downloads and counting + + + )} + Made with ❤️ by Tapframe and Friends @@ -888,6 +972,17 @@ const SettingsScreen: React.FC = () => { {renderCategoryContent('developer')} {renderCategoryContent('cache')} + {displayDownloads !== null && ( + + + {displayDownloads.toLocaleString()} + + + downloads and counting + + + )} + 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, diff --git a/src/services/githubReleaseService.ts b/src/services/githubReleaseService.ts index 4847189..b2696b7 100644 --- a/src/services/githubReleaseService.ts +++ b/src/services/githubReleaseService.ts @@ -59,4 +59,61 @@ export function isAnyUpgrade(current: string, latest: string): boolean { return b[2] > a[2]; } +export async function fetchTotalDownloads(): Promise { + 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 { + 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; + } +} +