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