From 206204998e7e7828abb3525a84ef15bf38218264 Mon Sep 17 00:00:00 2001 From: Nayif Noushad Date: Sun, 20 Apr 2025 12:49:38 +0530 Subject: [PATCH] Add Trakt integration and enhance SeriesContent component; include TraktProvider in App, update navigation for TraktSettings, and implement persistent season selection in useMetadata hook. --- App.tsx | 23 +- assets/trakt-logo.png | 2 + package-lock.json | 70 ++++ package.json | 3 + src/components/metadata/SeriesContent.tsx | 25 +- src/contexts/TraktContext.tsx | 37 ++ src/hooks/useMetadata.ts | 24 +- src/hooks/usePersistentSeasons.ts | 85 ++++ src/hooks/useTraktIntegration.ts | 146 +++++++ src/navigation/AppNavigator.tsx | 17 + src/screens/SettingsScreen.tsx | 6 +- src/screens/TraktSettingsScreen.tsx | 485 ++++++++++++++++++++++ src/services/traktService.ts | 462 +++++++++++++++++++++ 13 files changed, 1368 insertions(+), 17 deletions(-) create mode 100644 assets/trakt-logo.png create mode 100644 src/contexts/TraktContext.tsx create mode 100644 src/hooks/usePersistentSeasons.ts create mode 100644 src/hooks/useTraktIntegration.ts create mode 100644 src/screens/TraktSettingsScreen.tsx create mode 100644 src/services/traktService.ts diff --git a/App.tsx b/App.tsx index 24c504d..c4be52e 100644 --- a/App.tsx +++ b/App.tsx @@ -21,6 +21,7 @@ import AppNavigator, { import 'react-native-reanimated'; import { CatalogProvider } from './src/contexts/CatalogContext'; import { GenreProvider } from './src/contexts/GenreContext'; +import { TraktProvider } from './src/contexts/TraktContext'; function App(): React.JSX.Element { // Always use dark mode @@ -30,16 +31,18 @@ function App(): React.JSX.Element { - - - - - - - - + + + + + + + + + + diff --git a/assets/trakt-logo.png b/assets/trakt-logo.png new file mode 100644 index 0000000..3b237e4 --- /dev/null +++ b/assets/trakt-logo.png @@ -0,0 +1,2 @@ +// This is a placeholder for a binary PNG file +// Replace this file with an actual Trakt logo image \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 43c76f3..c11c22e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "axios": "^1.8.4", "date-fns": "^4.1.0", "expo": "~52.0.43", + "expo-auth-session": "^6.0.3", "expo-blur": "^14.0.3", "expo-file-system": "^18.0.12", "expo-haptics": "~14.0.1", @@ -31,9 +32,11 @@ "expo-intent-launcher": "~12.0.2", "expo-linear-gradient": "~14.0.2", "expo-notifications": "~0.29.14", + "expo-random": "^14.0.1", "expo-screen-orientation": "~8.0.4", "expo-status-bar": "~2.0.1", "expo-system-ui": "^4.0.9", + "expo-web-browser": "^14.0.2", "lodash": "^4.17.21", "react": "18.3.1", "react-native": "0.76.9", @@ -6630,6 +6633,24 @@ "react-native": "*" } }, + "node_modules/expo-auth-session": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-6.0.3.tgz", + "integrity": "sha512-s7LmmMPiiY1NXrlcXkc4+09Hlfw9X1CpaQOCDkwfQEodG1uCYGQi/WImTnDzw5YDkWI79uC8F1mB8EIerilkDA==", + "license": "MIT", + "dependencies": { + "expo-application": "~6.0.2", + "expo-constants": "~17.0.5", + "expo-crypto": "~14.0.2", + "expo-linking": "~7.0.5", + "expo-web-browser": "~14.0.2", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-blur": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-14.0.3.tgz", @@ -6655,6 +6676,18 @@ "react-native": "*" } }, + "node_modules/expo-crypto": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-14.0.2.tgz", + "integrity": "sha512-WRc9PBpJraJN29VD5Ef7nCecxJmZNyRKcGkNiDQC1nhY5agppzwhqh7zEzNFarE/GqDgSiaDHS8yd5EgFhP9AQ==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-file-system": { "version": "18.0.12", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.0.12.tgz", @@ -6737,6 +6770,20 @@ "react-native": "*" } }, + "node_modules/expo-linking": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-7.0.5.tgz", + "integrity": "sha512-3KptlJtcYDPWohk0MfJU75MJFh2ybavbtcSd84zEPfw9s1q3hjimw3sXnH03ZxP54kiEWldvKmmnGcVffBDB1g==", + "license": "MIT", + "dependencies": { + "expo-constants": "~17.0.5", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-2.0.8.tgz", @@ -6821,6 +6868,19 @@ "react-native": "*" } }, + "node_modules/expo-random": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/expo-random/-/expo-random-14.0.1.tgz", + "integrity": "sha512-gX2mtR9o+WelX21YizXUCD/y+a4ZL+RDthDmFkHxaYbdzjSYTn8u/igoje/l3WEO+/RYspmqUFa8w/ckNbt6Vg==", + "deprecated": "This package is now deprecated in favor of expo-crypto, which provides the same functionality. To migrate, replace all imports from expo-random with imports from expo-crypto.", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-screen-orientation": { "version": "8.0.4", "resolved": "https://registry.npmjs.org/expo-screen-orientation/-/expo-screen-orientation-8.0.4.tgz", @@ -6867,6 +6927,16 @@ "integrity": "sha512-FRjRvs7RgsXjkbGSOjYSxhX5V70c0IzA/jy3HXeYpATMwD9fOR1DbveLW497QGsVdCa0vThbJUtR8rIzAfpHQA==", "license": "MIT" }, + "node_modules/expo-web-browser": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-14.0.2.tgz", + "integrity": "sha512-Hncv2yojhTpHbP6SGWARBFdl7P6wBHc1O8IKaNsH0a/IEakq887o1eRhLxZ5IwztPQyRDhpqHdgJ+BjWolOnwA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/exponential-backoff": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", diff --git a/package.json b/package.json index 46b699a..669517d 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "axios": "^1.8.4", "date-fns": "^4.1.0", "expo": "~52.0.43", + "expo-auth-session": "^6.0.3", "expo-blur": "^14.0.3", "expo-file-system": "^18.0.12", "expo-haptics": "~14.0.1", @@ -32,9 +33,11 @@ "expo-intent-launcher": "~12.0.2", "expo-linear-gradient": "~14.0.2", "expo-notifications": "~0.29.14", + "expo-random": "^14.0.1", "expo-screen-orientation": "~8.0.4", "expo-status-bar": "~2.0.1", "expo-system-ui": "^4.0.9", + "expo-web-browser": "^14.0.2", "lodash": "^4.17.21", "react": "18.3.1", "react-native": "0.76.9", diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index 1382a19..c41cb89 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme } from 'react-native'; import { Image } from 'expo-image'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; @@ -37,6 +37,9 @@ export const SeriesContent: React.FC = ({ const isTablet = width > 768; const isDarkMode = useColorScheme() === 'dark'; const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: { currentTime: number; duration: number } }>({}); + + // Add ref for the season selector ScrollView + const seasonScrollViewRef = useRef(null); const loadEpisodesProgress = async () => { if (!metadata?.id) return; @@ -70,6 +73,25 @@ export const SeriesContent: React.FC = ({ }, [episodes, metadata?.id]) ); + // Add effect to scroll to selected season + useEffect(() => { + if (selectedSeason && seasonScrollViewRef.current && Object.keys(groupedEpisodes).length > 0) { + // Find the index of the selected season + const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b); + const selectedIndex = seasons.findIndex(season => season === selectedSeason); + + if (selectedIndex !== -1) { + // Wait a small amount of time for layout to be ready + setTimeout(() => { + seasonScrollViewRef.current?.scrollTo({ + x: selectedIndex * 116, // 100px width + 16px margin + animated: true + }); + }, 300); + } + } + }, [selectedSeason, groupedEpisodes]); + if (loadingSeasons) { return ( @@ -99,6 +121,7 @@ export const SeriesContent: React.FC = ({ Seasons Promise; + loadWatchedItems: () => Promise; + isMovieWatched: (imdbId: string) => Promise; + isEpisodeWatched: (imdbId: string, season: number, episode: number) => Promise; + markMovieAsWatched: (imdbId: string, watchedAt?: Date) => Promise; + markEpisodeAsWatched: (imdbId: string, season: number, episode: number, watchedAt?: Date) => Promise; +} + +const TraktContext = createContext(undefined); + +export function TraktProvider({ children }: { children: ReactNode }) { + const traktIntegration = useTraktIntegration(); + + return ( + + {children} + + ); +} + +export function useTraktContext() { + const context = useContext(TraktContext); + if (context === undefined) { + throw new Error('useTraktContext must be used within a TraktProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index 311d438..cef08d8 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -7,6 +7,7 @@ import { cacheService } from '../services/cacheService'; import { Cast, Episode, GroupedEpisodes, GroupedStreams } from '../types/metadata'; import { TMDBService } from '../services/tmdbService'; import { logger } from '../utils/logger'; +import { usePersistentSeasons } from './usePersistentSeasons'; // Constants for timeouts and retries const API_TIMEOUT = 10000; // 10 seconds @@ -113,6 +114,9 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = const [loadingRecommendations, setLoadingRecommendations] = useState(false); const [imdbId, setImdbId] = useState(null); + // Add hook for persistent seasons + const { getSeason, saveSeason } = usePersistentSeasons(); + const processStremioSource = async (type: string, id: string, isEpisode = false) => { const sourceStartTime = Date.now(); const logPrefix = isEpisode ? 'loadEpisodeStreams' : 'loadStreams'; @@ -575,10 +579,17 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = setGroupedEpisodes(transformedEpisodes); + // Get the first available season as fallback const firstSeason = Math.min(...Object.keys(allEpisodes).map(Number)); - const initialEpisodes = transformedEpisodes[firstSeason] || []; - setSelectedSeason(firstSeason); - setEpisodes(initialEpisodes); + + // Get saved season from persistence, fallback to first season if not found + const persistedSeason = getSeason(id, firstSeason); + + // Set the selected season from persistence + setSelectedSeason(persistedSeason); + + // Set episodes for the selected season + setEpisodes(transformedEpisodes[persistedSeason] || []); } } catch (error) { console.error('Failed to load episodes:', error); @@ -958,9 +969,14 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = const handleSeasonChange = useCallback((seasonNumber: number) => { if (selectedSeason === seasonNumber) return; + + // Update local state setSelectedSeason(seasonNumber); setEpisodes(groupedEpisodes[seasonNumber] || []); - }, [selectedSeason, groupedEpisodes]); + + // Persist the selection + saveSeason(id, seasonNumber); + }, [selectedSeason, groupedEpisodes, saveSeason, id]); const toggleLibrary = useCallback(() => { if (!metadata) return; diff --git a/src/hooks/usePersistentSeasons.ts b/src/hooks/usePersistentSeasons.ts new file mode 100644 index 0000000..bfb6bc7 --- /dev/null +++ b/src/hooks/usePersistentSeasons.ts @@ -0,0 +1,85 @@ +import { useState, useEffect, useCallback } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { logger } from '../utils/logger'; + +const SEASONS_STORAGE_KEY = 'selected_seasons'; + +interface SeasonsCache { + seasons: { [seriesId: string]: number }; + lastUpdate: number; +} + +// Simple in-memory cache to avoid repeated AsyncStorage reads within the same session +let cache: SeasonsCache | null = null; + +export function usePersistentSeasons() { + const [selectedSeasons, setSelectedSeasons] = useState<{ [seriesId: string]: number } | null>(cache?.seasons || null); + const [isLoading, setIsLoading] = useState(!cache); // Only loading if cache is empty + + const loadSelectedSeasons = useCallback(async () => { + // Check if cache is recent enough (within last 5 minutes) + const now = Date.now(); + if (cache && (now - cache.lastUpdate < 5 * 60 * 1000)) { + if (!selectedSeasons) setSelectedSeasons(cache.seasons); // Ensure state is updated if cache existed + setIsLoading(false); + return; + } + + setIsLoading(true); + try { + const savedSeasonsJson = await AsyncStorage.getItem(SEASONS_STORAGE_KEY); + const loadedSeasons = savedSeasonsJson ? JSON.parse(savedSeasonsJson) : {}; + setSelectedSeasons(loadedSeasons); + // Update cache + cache = { seasons: loadedSeasons, lastUpdate: now }; + } catch (error) { + logger.error('Failed to load persistent seasons:', error); + setSelectedSeasons({}); // Set to empty object on error + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + loadSelectedSeasons(); + }, [loadSelectedSeasons]); + + const saveSeason = useCallback(async (seriesId: string, seasonNumber: number) => { + if (!selectedSeasons) return; + + try { + const updatedSeasons = { + ...selectedSeasons, + [seriesId]: seasonNumber + }; + + // Update the cache + cache = { + seasons: updatedSeasons, + lastUpdate: Date.now() + }; + + // Update state + setSelectedSeasons(updatedSeasons); + + // Save to AsyncStorage + await AsyncStorage.setItem(SEASONS_STORAGE_KEY, JSON.stringify(updatedSeasons)); + } catch (error) { + logger.error('Failed to save selected season:', error); + } + }, [selectedSeasons]); + + const getSeason = useCallback((seriesId: string, defaultSeason: number = 1): number => { + if (isLoading || !selectedSeasons) { + return defaultSeason; + } + return selectedSeasons[seriesId] || defaultSeason; + }, [selectedSeasons, isLoading]); + + return { + getSeason, + saveSeason, + isLoadingSeasons: isLoading, + refreshSeasons: loadSelectedSeasons + }; +} \ No newline at end of file diff --git a/src/hooks/useTraktIntegration.ts b/src/hooks/useTraktIntegration.ts new file mode 100644 index 0000000..d89ac19 --- /dev/null +++ b/src/hooks/useTraktIntegration.ts @@ -0,0 +1,146 @@ +import { useState, useEffect, useCallback } from 'react'; +import { traktService, TraktUser, TraktWatchedItem } from '../services/traktService'; +import { logger } from '../utils/logger'; + +export function useTraktIntegration() { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [userProfile, setUserProfile] = useState(null); + const [watchedMovies, setWatchedMovies] = useState([]); + const [watchedShows, setWatchedShows] = useState([]); + + // Check authentication status + const checkAuthStatus = useCallback(async () => { + setIsLoading(true); + try { + const authenticated = await traktService.isAuthenticated(); + setIsAuthenticated(authenticated); + + if (authenticated) { + const profile = await traktService.getUserProfile(); + setUserProfile(profile); + } else { + setUserProfile(null); + } + } catch (error) { + logger.error('[useTraktIntegration] Error checking auth status:', error); + } finally { + setIsLoading(false); + } + }, []); + + // Load watched items + const loadWatchedItems = useCallback(async () => { + if (!isAuthenticated) return; + + setIsLoading(true); + try { + const [movies, shows] = await Promise.all([ + traktService.getWatchedMovies(), + traktService.getWatchedShows() + ]); + setWatchedMovies(movies); + setWatchedShows(shows); + } catch (error) { + logger.error('[useTraktIntegration] Error loading watched items:', error); + } finally { + setIsLoading(false); + } + }, [isAuthenticated]); + + // Check if a movie is watched + const isMovieWatched = useCallback(async (imdbId: string): Promise => { + if (!isAuthenticated) return false; + + try { + return await traktService.isMovieWatched(imdbId); + } catch (error) { + logger.error('[useTraktIntegration] Error checking if movie is watched:', error); + return false; + } + }, [isAuthenticated]); + + // Check if an episode is watched + const isEpisodeWatched = useCallback(async ( + imdbId: string, + season: number, + episode: number + ): Promise => { + if (!isAuthenticated) return false; + + try { + return await traktService.isEpisodeWatched(imdbId, season, episode); + } catch (error) { + logger.error('[useTraktIntegration] Error checking if episode is watched:', error); + return false; + } + }, [isAuthenticated]); + + // Mark a movie as watched + const markMovieAsWatched = useCallback(async ( + imdbId: string, + watchedAt: Date = new Date() + ): Promise => { + if (!isAuthenticated) return false; + + try { + const result = await traktService.addToWatchedMovies(imdbId, watchedAt); + if (result) { + // Refresh watched movies list + await loadWatchedItems(); + } + return result; + } catch (error) { + logger.error('[useTraktIntegration] Error marking movie as watched:', error); + return false; + } + }, [isAuthenticated, loadWatchedItems]); + + // Mark an episode as watched + const markEpisodeAsWatched = useCallback(async ( + imdbId: string, + season: number, + episode: number, + watchedAt: Date = new Date() + ): Promise => { + if (!isAuthenticated) return false; + + try { + const result = await traktService.addToWatchedEpisodes(imdbId, season, episode, watchedAt); + if (result) { + // Refresh watched shows list + await loadWatchedItems(); + } + return result; + } catch (error) { + logger.error('[useTraktIntegration] Error marking episode as watched:', error); + return false; + } + }, [isAuthenticated, loadWatchedItems]); + + // Initialize and check auth status + useEffect(() => { + checkAuthStatus(); + }, [checkAuthStatus]); + + // Load watched items when authenticated + useEffect(() => { + if (isAuthenticated) { + loadWatchedItems(); + } + }, [isAuthenticated, loadWatchedItems]); + + return { + isAuthenticated, + isLoading, + userProfile, + watchedMovies, + watchedShows, + checkAuthStatus, + loadWatchedItems, + isMovieWatched, + isEpisodeWatched, + markMovieAsWatched, + markEpisodeAsWatched + }; +} \ No newline at end of file diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 592f2b9..17728d8 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -32,6 +32,7 @@ import MDBListSettingsScreen from '../screens/MDBListSettingsScreen'; import TMDBSettingsScreen from '../screens/TMDBSettingsScreen'; import HomeScreenSettings from '../screens/HomeScreenSettings'; import HeroCatalogsScreen from '../screens/HeroCatalogsScreen'; +import TraktSettingsScreen from '../screens/TraktSettingsScreen'; // Stack navigator types export type RootStackParamList = { @@ -85,6 +86,7 @@ export type RootStackParamList = { TMDBSettings: undefined; HomeScreenSettings: undefined; HeroCatalogs: undefined; + TraktSettings: undefined; }; export type RootStackNavigationProp = NativeStackNavigationProp; @@ -698,6 +700,21 @@ const AppNavigator = () => { }, }} /> + diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 2d5e726..54b3955 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -23,6 +23,7 @@ import { useSettings, DEFAULT_SETTINGS } from '../hooks/useSettings'; import { RootStackParamList } from '../navigation/AppNavigator'; import { stremioService } from '../services/stremioService'; import { useCatalogContext } from '../contexts/CatalogContext'; +import { useTraktContext } from '../contexts/TraktContext'; const { width } = Dimensions.get('window'); @@ -111,6 +112,7 @@ const SettingsScreen: React.FC = () => { const isDarkMode = systemColorScheme === 'dark' || settings.enableDarkMode; const navigation = useNavigation>(); const { lastUpdate } = useCatalogContext(); + const { isAuthenticated, userProfile } = useTraktContext(); // States for dynamic content const [addonCount, setAddonCount] = useState(0); @@ -225,11 +227,11 @@ const SettingsScreen: React.FC = () => { Alert.alert('Trakt', 'Trakt integration coming soon')} + onPress={() => navigation.navigate('TraktSettings')} /> { + const { settings } = useSettings(); + const isDarkMode = settings.enableDarkMode; + const navigation = useNavigation(); + const [isLoading, setIsLoading] = useState(true); + const [isAuthenticating, setIsAuthenticating] = useState(false); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [userProfile, setUserProfile] = useState(null); + + const checkAuthStatus = useCallback(async () => { + setIsLoading(true); + try { + const authenticated = await traktService.isAuthenticated(); + setIsAuthenticated(authenticated); + + if (authenticated) { + const profile = await traktService.getUserProfile(); + setUserProfile(profile); + } + } catch (error) { + logger.error('[TraktSettingsScreen] Error checking auth status:', error); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + checkAuthStatus(); + }, [checkAuthStatus]); + + // Handle deep linking when returning from Trakt authorization + useEffect(() => { + const handleRedirect = async (event: { url: string }) => { + const { url } = event; + if (url.includes('auth/trakt')) { + setIsAuthenticating(true); + try { + const code = url.split('code=')[1].split('&')[0]; + const success = await traktService.exchangeCodeForToken(code); + if (success) { + checkAuthStatus(); + } else { + Alert.alert('Authentication Error', 'Failed to complete authentication with Trakt.'); + } + } catch (error) { + logger.error('[TraktSettingsScreen] Authentication error:', error); + Alert.alert('Authentication Error', 'An error occurred during authentication.'); + } finally { + setIsAuthenticating(false); + } + } + }; + + // Add event listener for deep linking + const subscription = Linking.addEventListener('url', handleRedirect); + + return () => { + subscription.remove(); + }; + }, [checkAuthStatus]); + + const handleSignIn = async () => { + try { + const authUrl = traktService.getAuthUrl(); + await WebBrowser.openAuthSessionAsync(authUrl, redirectUri); + } catch (error) { + logger.error('[TraktSettingsScreen] Error opening auth session:', error); + Alert.alert('Authentication Error', 'Could not open Trakt authentication page.'); + } + }; + + const handleSignOut = async () => { + Alert.alert( + 'Sign Out', + 'Are you sure you want to sign out of your Trakt account?', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Sign Out', + style: 'destructive', + onPress: async () => { + setIsLoading(true); + try { + await traktService.logout(); + setIsAuthenticated(false); + setUserProfile(null); + } catch (error) { + logger.error('[TraktSettingsScreen] Error signing out:', error); + Alert.alert('Error', 'Failed to sign out of Trakt.'); + } finally { + setIsLoading(false); + } + } + } + ] + ); + }; + + return ( + + + + navigation.goBack()} + style={styles.backButton} + > + + + + Trakt Settings + + + + + + {isLoading ? ( + + + + ) : isAuthenticated && userProfile ? ( + + + {userProfile.avatar ? ( + + ) : ( + + + {userProfile.name?.charAt(0) || userProfile.username.charAt(0)} + + + )} + + + {userProfile.name || userProfile.username} + + + @{userProfile.username} + + {userProfile.vip && ( + + VIP + + )} + + + + + + Joined {new Date(userProfile.joined_at).toLocaleDateString()} + + + + + + Sign Out + + + + ) : ( + + + + Connect with Trakt + + + Sync your watch history, watchlist, and collection with Trakt.tv + + + {isAuthenticating ? ( + + ) : ( + + Sign In with Trakt + + )} + + + )} + + + {isAuthenticated && ( + + + + Sync Settings + + + + Auto-sync playback progress + + + Coming soon + + + + + Import watched history + + + Coming soon + + + + + Sync Now (Coming Soon) + + + + + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 16, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 16 : 16, + }, + backButton: { + padding: 4, + }, + headerTitle: { + fontSize: 22, + fontWeight: '600', + marginLeft: 16, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingHorizontal: 16, + paddingBottom: 32, + }, + card: { + borderRadius: 12, + overflow: 'hidden', + marginBottom: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + loadingContainer: { + padding: 40, + alignItems: 'center', + justifyContent: 'center', + }, + signInContainer: { + padding: 24, + alignItems: 'center', + }, + traktLogo: { + width: 120, + height: 120, + marginBottom: 20, + }, + signInTitle: { + fontSize: 20, + fontWeight: '600', + marginBottom: 8, + textAlign: 'center', + }, + signInDescription: { + fontSize: 15, + textAlign: 'center', + marginBottom: 24, + paddingHorizontal: 20, + }, + button: { + width: '100%', + height: 44, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + marginTop: 8, + }, + signOutButton: { + marginTop: 20, + }, + buttonText: { + fontSize: 16, + fontWeight: '500', + color: 'white', + }, + profileContainer: { + padding: 20, + }, + profileHeader: { + flexDirection: 'row', + alignItems: 'center', + }, + avatar: { + width: 64, + height: 64, + borderRadius: 32, + }, + avatarPlaceholder: { + width: 64, + height: 64, + borderRadius: 32, + alignItems: 'center', + justifyContent: 'center', + }, + avatarText: { + fontSize: 26, + fontWeight: 'bold', + color: 'white', + }, + profileInfo: { + marginLeft: 16, + flex: 1, + }, + profileName: { + fontSize: 18, + fontWeight: '600', + marginBottom: 4, + }, + profileUsername: { + fontSize: 14, + }, + vipBadge: { + marginTop: 4, + paddingHorizontal: 8, + paddingVertical: 2, + backgroundColor: '#FFD700', + borderRadius: 4, + alignSelf: 'flex-start', + }, + vipText: { + fontSize: 10, + fontWeight: 'bold', + color: '#000', + }, + statsContainer: { + marginTop: 16, + paddingTop: 16, + borderTopWidth: 0.5, + borderTopColor: 'rgba(150,150,150,0.2)', + }, + joinedDate: { + fontSize: 14, + }, + settingsSection: { + padding: 20, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '600', + marginBottom: 16, + }, + settingItem: { + marginBottom: 16, + }, + settingLabel: { + fontSize: 15, + fontWeight: '500', + marginBottom: 4, + }, + settingDescription: { + fontSize: 14, + }, +}); + +export default TraktSettingsScreen; \ No newline at end of file diff --git a/src/services/traktService.ts b/src/services/traktService.ts new file mode 100644 index 0000000..3030040 --- /dev/null +++ b/src/services/traktService.ts @@ -0,0 +1,462 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { logger } from '../utils/logger'; + +// Storage keys +export const TRAKT_ACCESS_TOKEN_KEY = 'trakt_access_token'; +export const TRAKT_REFRESH_TOKEN_KEY = 'trakt_refresh_token'; +export const TRAKT_TOKEN_EXPIRY_KEY = 'trakt_token_expiry'; + +// Trakt API configuration +const TRAKT_API_URL = 'https://api.trakt.tv'; +const TRAKT_CLIENT_ID = 'd7271f7dd57d8aeff63e99408610091a6b1ceac3b3a541d1031a48f429b7942c'; +const TRAKT_CLIENT_SECRET = '0abf42c39aaad72c74696fb5229b558a6ac4b747caf3d380d939e950e8a5449c'; +const TRAKT_REDIRECT_URI = 'stremioexpo://auth/trakt'; // This should match your registered callback URL + +// Types +export interface TraktUser { + username: string; + name?: string; + private: boolean; + vip: boolean; + joined_at: string; + avatar?: string; +} + +export interface TraktWatchedItem { + movie?: { + title: string; + year: number; + ids: { + trakt: number; + slug: string; + imdb: string; + tmdb: number; + }; + }; + show?: { + title: string; + year: number; + ids: { + trakt: number; + slug: string; + imdb: string; + tmdb: number; + }; + }; + plays: number; + last_watched_at: string; +} + +export class TraktService { + private static instance: TraktService; + private accessToken: string | null = null; + private refreshToken: string | null = null; + private tokenExpiry: number = 0; + private isInitialized: boolean = false; + + private constructor() { + // Initialization happens in initialize method + } + + public static getInstance(): TraktService { + if (!TraktService.instance) { + TraktService.instance = new TraktService(); + } + return TraktService.instance; + } + + /** + * Initialize the Trakt service by loading stored tokens + */ + public async initialize(): Promise { + if (this.isInitialized) { + return; + } + + try { + const [accessToken, refreshToken, tokenExpiry] = await Promise.all([ + AsyncStorage.getItem(TRAKT_ACCESS_TOKEN_KEY), + AsyncStorage.getItem(TRAKT_REFRESH_TOKEN_KEY), + AsyncStorage.getItem(TRAKT_TOKEN_EXPIRY_KEY) + ]); + + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.tokenExpiry = tokenExpiry ? parseInt(tokenExpiry, 10) : 0; + this.isInitialized = true; + + logger.log('[TraktService] Initialized, authenticated:', !!this.accessToken); + } catch (error) { + logger.error('[TraktService] Initialization failed:', error); + throw error; + } + } + + /** + * Check if the user is authenticated with Trakt + */ + public async isAuthenticated(): Promise { + await this.ensureInitialized(); + + if (!this.accessToken) { + return false; + } + + // Check if token is expired and needs refresh + if (this.tokenExpiry && this.tokenExpiry < Date.now() && this.refreshToken) { + try { + await this.refreshAccessToken(); + return !!this.accessToken; + } catch { + return false; + } + } + + return true; + } + + /** + * Get the authentication URL for Trakt OAuth + */ + public getAuthUrl(): string { + return `https://trakt.tv/oauth/authorize?response_type=code&client_id=${TRAKT_CLIENT_ID}&redirect_uri=${encodeURIComponent(TRAKT_REDIRECT_URI)}`; + } + + /** + * Exchange the authorization code for an access token + */ + public async exchangeCodeForToken(code: string): Promise { + await this.ensureInitialized(); + + try { + const response = await fetch(`${TRAKT_API_URL}/oauth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + code, + client_id: TRAKT_CLIENT_ID, + client_secret: TRAKT_CLIENT_SECRET, + redirect_uri: TRAKT_REDIRECT_URI, + grant_type: 'authorization_code' + }) + }); + + if (!response.ok) { + throw new Error(`Failed to exchange code: ${response.status}`); + } + + const data = await response.json(); + await this.saveTokens(data.access_token, data.refresh_token, data.expires_in); + return true; + } catch (error) { + logger.error('[TraktService] Failed to exchange code for token:', error); + return false; + } + } + + /** + * Refresh the access token using the refresh token + */ + private async refreshAccessToken(): Promise { + if (!this.refreshToken) { + throw new Error('No refresh token available'); + } + + try { + const response = await fetch(`${TRAKT_API_URL}/oauth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + refresh_token: this.refreshToken, + client_id: TRAKT_CLIENT_ID, + client_secret: TRAKT_CLIENT_SECRET, + redirect_uri: TRAKT_REDIRECT_URI, + grant_type: 'refresh_token' + }) + }); + + if (!response.ok) { + throw new Error(`Failed to refresh token: ${response.status}`); + } + + const data = await response.json(); + await this.saveTokens(data.access_token, data.refresh_token, data.expires_in); + } catch (error) { + logger.error('[TraktService] Failed to refresh token:', error); + await this.logout(); // Clear tokens if refresh fails + throw error; + } + } + + /** + * Save authentication tokens to storage + */ + private async saveTokens(accessToken: string, refreshToken: string, expiresIn: number): Promise { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.tokenExpiry = Date.now() + (expiresIn * 1000); + + try { + await AsyncStorage.multiSet([ + [TRAKT_ACCESS_TOKEN_KEY, accessToken], + [TRAKT_REFRESH_TOKEN_KEY, refreshToken], + [TRAKT_TOKEN_EXPIRY_KEY, this.tokenExpiry.toString()] + ]); + logger.log('[TraktService] Tokens saved successfully'); + } catch (error) { + logger.error('[TraktService] Failed to save tokens:', error); + throw error; + } + } + + /** + * Log out the user by clearing all tokens + */ + public async logout(): Promise { + await this.ensureInitialized(); + + try { + this.accessToken = null; + this.refreshToken = null; + this.tokenExpiry = 0; + + await AsyncStorage.multiRemove([ + TRAKT_ACCESS_TOKEN_KEY, + TRAKT_REFRESH_TOKEN_KEY, + TRAKT_TOKEN_EXPIRY_KEY + ]); + logger.log('[TraktService] Logged out successfully'); + } catch (error) { + logger.error('[TraktService] Failed to logout:', error); + throw error; + } + } + + /** + * Ensure the service is initialized before performing operations + */ + private async ensureInitialized(): Promise { + if (!this.isInitialized) { + await this.initialize(); + } + } + + /** + * Make an authenticated API request to Trakt + */ + private async apiRequest( + endpoint: string, + method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', + body?: any + ): Promise { + await this.ensureInitialized(); + + // Ensure we have a valid token + if (this.tokenExpiry && this.tokenExpiry < Date.now() && this.refreshToken) { + await this.refreshAccessToken(); + } + + if (!this.accessToken) { + throw new Error('Not authenticated'); + } + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + 'trakt-api-version': '2', + 'trakt-api-key': TRAKT_CLIENT_ID, + 'Authorization': `Bearer ${this.accessToken}` + }; + + const options: RequestInit = { + method, + headers + }; + + if (body) { + options.body = JSON.stringify(body); + } + + const response = await fetch(`${TRAKT_API_URL}${endpoint}`, options); + + if (!response.ok) { + throw new Error(`API request failed: ${response.status}`); + } + + return await response.json() as T; + } + + /** + * Get the user's profile information + */ + public async getUserProfile(): Promise { + return this.apiRequest('/users/me?extended=full'); + } + + /** + * Get the user's watched movies + */ + public async getWatchedMovies(): Promise { + return this.apiRequest('/sync/watched/movies'); + } + + /** + * Get the user's watched shows + */ + public async getWatchedShows(): Promise { + return this.apiRequest('/sync/watched/shows'); + } + + /** + * Get trakt id from IMDb id + */ + public async getTraktIdFromImdbId(imdbId: string, type: 'movies' | 'shows'): Promise { + try { + const response = await fetch(`${TRAKT_API_URL}/search/${type}?id_type=imdb&id=${imdbId}`, { + headers: { + 'Content-Type': 'application/json', + 'trakt-api-version': '2', + 'trakt-api-key': TRAKT_CLIENT_ID + } + }); + + if (!response.ok) { + throw new Error(`Failed to get Trakt ID: ${response.status}`); + } + + const data = await response.json(); + if (data && data.length > 0) { + return data[0][type.slice(0, -1)].ids.trakt; + } + return null; + } catch (error) { + logger.error('[TraktService] Failed to get Trakt ID from IMDb ID:', error); + return null; + } + } + + /** + * Add a movie to user's watched history + */ + public async addToWatchedMovies(imdbId: string, watchedAt: Date = new Date()): Promise { + try { + const traktId = await this.getTraktIdFromImdbId(imdbId, 'movies'); + if (!traktId) { + return false; + } + + await this.apiRequest('/sync/history', 'POST', { + movies: [ + { + ids: { + trakt: traktId + }, + watched_at: watchedAt.toISOString() + } + ] + }); + return true; + } catch (error) { + logger.error('[TraktService] Failed to mark movie as watched:', error); + return false; + } + } + + /** + * Add a show episode to user's watched history + */ + public async addToWatchedEpisodes( + imdbId: string, + season: number, + episode: number, + watchedAt: Date = new Date() + ): Promise { + try { + const traktId = await this.getTraktIdFromImdbId(imdbId, 'shows'); + if (!traktId) { + return false; + } + + await this.apiRequest('/sync/history', 'POST', { + episodes: [ + { + ids: { + trakt: traktId + }, + seasons: [ + { + number: season, + episodes: [ + { + number: episode, + watched_at: watchedAt.toISOString() + } + ] + } + ] + } + ] + }); + return true; + } catch (error) { + logger.error('[TraktService] Failed to mark episode as watched:', error); + return false; + } + } + + /** + * Check if a movie is in user's watched history + */ + public async isMovieWatched(imdbId: string): Promise { + try { + if (!this.accessToken) { + return false; + } + + const traktId = await this.getTraktIdFromImdbId(imdbId, 'movies'); + if (!traktId) { + return false; + } + + const response = await this.apiRequest(`/sync/history/movies/${traktId}`); + return response.length > 0; + } catch (error) { + logger.error('[TraktService] Failed to check if movie is watched:', error); + return false; + } + } + + /** + * Check if a show episode is in user's watched history + */ + public async isEpisodeWatched( + imdbId: string, + season: number, + episode: number + ): Promise { + try { + if (!this.accessToken) { + return false; + } + + const traktId = await this.getTraktIdFromImdbId(imdbId, 'shows'); + if (!traktId) { + return false; + } + + const response = await this.apiRequest( + `/sync/history/episodes/${traktId}?season=${season}&episode=${episode}` + ); + return response.length > 0; + } catch (error) { + logger.error('[TraktService] Failed to check if episode is watched:', error); + return false; + } + } +} + +// Export a singleton instance +export const traktService = TraktService.getInstance(); \ No newline at end of file