Add Trakt integration and enhance SeriesContent component; include TraktProvider in App, update navigation for TraktSettings, and implement persistent season selection in useMetadata hook.

This commit is contained in:
Nayif Noushad 2025-04-20 12:49:38 +05:30
parent 62371d4bf5
commit 206204998e
13 changed files with 1368 additions and 17 deletions

23
App.tsx
View file

@ -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 {
<GestureHandlerRootView style={{ flex: 1 }}>
<GenreProvider>
<CatalogProvider>
<PaperProvider theme={CustomDarkTheme}>
<NavigationContainer theme={CustomNavigationDarkTheme}>
<View style={[styles.container, { backgroundColor: '#000000' }]}>
<StatusBar
style="light"
/>
<AppNavigator />
</View>
</NavigationContainer>
</PaperProvider>
<TraktProvider>
<PaperProvider theme={CustomDarkTheme}>
<NavigationContainer theme={CustomNavigationDarkTheme}>
<View style={[styles.container, { backgroundColor: '#000000' }]}>
<StatusBar
style="light"
/>
<AppNavigator />
</View>
</NavigationContainer>
</PaperProvider>
</TraktProvider>
</CatalogProvider>
</GenreProvider>
</GestureHandlerRootView>

2
assets/trakt-logo.png Normal file
View file

@ -0,0 +1,2 @@
// This is a placeholder for a binary PNG file
// Replace this file with an actual Trakt logo image

70
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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<SeriesContentProps> = ({
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<ScrollView | null>(null);
const loadEpisodesProgress = async () => {
if (!metadata?.id) return;
@ -70,6 +73,25 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
}, [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 (
<View style={styles.centeredContainer}>
@ -99,6 +121,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
<View style={styles.seasonSelectorWrapper}>
<Text style={styles.seasonSelectorTitle}>Seasons</Text>
<ScrollView
ref={seasonScrollViewRef}
horizontal
showsHorizontalScrollIndicator={false}
style={styles.seasonSelectorContainer}

View file

@ -0,0 +1,37 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { useTraktIntegration } from '../hooks/useTraktIntegration';
import { TraktUser, TraktWatchedItem } from '../services/traktService';
interface TraktContextProps {
isAuthenticated: boolean;
isLoading: boolean;
userProfile: TraktUser | null;
watchedMovies: TraktWatchedItem[];
watchedShows: TraktWatchedItem[];
checkAuthStatus: () => Promise<void>;
loadWatchedItems: () => Promise<void>;
isMovieWatched: (imdbId: string) => Promise<boolean>;
isEpisodeWatched: (imdbId: string, season: number, episode: number) => Promise<boolean>;
markMovieAsWatched: (imdbId: string, watchedAt?: Date) => Promise<boolean>;
markEpisodeAsWatched: (imdbId: string, season: number, episode: number, watchedAt?: Date) => Promise<boolean>;
}
const TraktContext = createContext<TraktContextProps | undefined>(undefined);
export function TraktProvider({ children }: { children: ReactNode }) {
const traktIntegration = useTraktIntegration();
return (
<TraktContext.Provider value={traktIntegration}>
{children}
</TraktContext.Provider>
);
}
export function useTraktContext() {
const context = useContext(TraktContext);
if (context === undefined) {
throw new Error('useTraktContext must be used within a TraktProvider');
}
return context;
}

View file

@ -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<string | null>(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;

View file

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

View file

@ -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<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [userProfile, setUserProfile] = useState<TraktUser | null>(null);
const [watchedMovies, setWatchedMovies] = useState<TraktWatchedItem[]>([]);
const [watchedShows, setWatchedShows] = useState<TraktWatchedItem[]>([]);
// 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<boolean> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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
};
}

View file

@ -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<RootStackParamList>;
@ -698,6 +700,21 @@ const AppNavigator = () => {
},
}}
/>
<Stack.Screen
name="TraktSettings"
component={TraktSettingsScreen}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: colors.darkBackground,
},
}}
/>
</Stack.Navigator>
</PaperProvider>
</>

View file

@ -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<NavigationProp<RootStackParamList>>();
const { lastUpdate } = useCatalogContext();
const { isAuthenticated, userProfile } = useTraktContext();
// States for dynamic content
const [addonCount, setAddonCount] = useState<number>(0);
@ -225,11 +227,11 @@ const SettingsScreen: React.FC = () => {
<SettingsCard isDarkMode={isDarkMode}>
<SettingItem
title="Trakt"
description="Not Connected"
description={isAuthenticated ? `Connected as ${userProfile?.username || 'User'}` : "Not Connected"}
icon="person"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => Alert.alert('Trakt', 'Trakt integration coming soon')}
onPress={() => navigation.navigate('TraktSettings')}
/>
<SettingItem
title="iCloud Sync"

View file

@ -0,0 +1,485 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
Alert,
Image,
SafeAreaView,
ScrollView,
StatusBar,
Platform,
Linking
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import * as WebBrowser from 'expo-web-browser';
import { makeRedirectUri } from 'expo-auth-session';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import { traktService, TraktUser } from '../services/traktService';
import { colors } from '../styles/colors';
import { useSettings } from '../hooks/useSettings';
import { logger } from '../utils/logger';
import TraktIcon from '../../assets/rating-icons/trakt.svg';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
// For use with deep linking
const redirectUri = makeRedirectUri({
scheme: 'stremioexpo',
path: 'auth/trakt',
});
const TraktSettingsScreen: React.FC = () => {
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<TraktUser | null>(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 (
<SafeAreaView style={[
styles.container,
{ backgroundColor: isDarkMode ? colors.darkBackground : '#F2F2F7' }
]}>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<View style={styles.header}>
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backButton}
>
<MaterialIcons
name="arrow-back"
size={24}
color={isDarkMode ? colors.highEmphasis : colors.textDark}
/>
</TouchableOpacity>
<Text style={[
styles.headerTitle,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark }
]}>
Trakt Settings
</Text>
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
<View style={[
styles.card,
{ backgroundColor: isDarkMode ? colors.elevation2 : colors.white }
]}>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
) : isAuthenticated && userProfile ? (
<View style={styles.profileContainer}>
<View style={styles.profileHeader}>
{userProfile.avatar ? (
<Image
source={{ uri: userProfile.avatar }}
style={styles.avatar}
/>
) : (
<View style={[styles.avatarPlaceholder, { backgroundColor: colors.primary }]}>
<Text style={styles.avatarText}>
{userProfile.name?.charAt(0) || userProfile.username.charAt(0)}
</Text>
</View>
)}
<View style={styles.profileInfo}>
<Text style={[
styles.profileName,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark }
]}>
{userProfile.name || userProfile.username}
</Text>
<Text style={[
styles.profileUsername,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
]}>
@{userProfile.username}
</Text>
{userProfile.vip && (
<View style={styles.vipBadge}>
<Text style={styles.vipText}>VIP</Text>
</View>
)}
</View>
</View>
<View style={styles.statsContainer}>
<Text style={[
styles.joinedDate,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
]}>
Joined {new Date(userProfile.joined_at).toLocaleDateString()}
</Text>
</View>
<TouchableOpacity
style={[
styles.button,
styles.signOutButton,
{ backgroundColor: isDarkMode ? 'rgba(255,59,48,0.1)' : 'rgba(255,59,48,0.08)' }
]}
onPress={handleSignOut}
>
<Text style={[styles.buttonText, { color: '#FF3B30' }]}>
Sign Out
</Text>
</TouchableOpacity>
</View>
) : (
<View style={styles.signInContainer}>
<TraktIcon
width={120}
height={120}
style={styles.traktLogo}
/>
<Text style={[
styles.signInTitle,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark }
]}>
Connect with Trakt
</Text>
<Text style={[
styles.signInDescription,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
]}>
Sync your watch history, watchlist, and collection with Trakt.tv
</Text>
<TouchableOpacity
style={[
styles.button,
{ backgroundColor: isDarkMode ? colors.primary : colors.primary }
]}
onPress={handleSignIn}
disabled={isAuthenticating}
>
{isAuthenticating ? (
<ActivityIndicator size="small" color="white" />
) : (
<Text style={styles.buttonText}>
Sign In with Trakt
</Text>
)}
</TouchableOpacity>
</View>
)}
</View>
{isAuthenticated && (
<View style={[
styles.card,
{ backgroundColor: isDarkMode ? colors.elevation2 : colors.white }
]}>
<View style={styles.settingsSection}>
<Text style={[
styles.sectionTitle,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark }
]}>
Sync Settings
</Text>
<View style={styles.settingItem}>
<Text style={[
styles.settingLabel,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark }
]}>
Auto-sync playback progress
</Text>
<Text style={[
styles.settingDescription,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
]}>
Coming soon
</Text>
</View>
<View style={styles.settingItem}>
<Text style={[
styles.settingLabel,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark }
]}>
Import watched history
</Text>
<Text style={[
styles.settingDescription,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
]}>
Coming soon
</Text>
</View>
<TouchableOpacity
style={[
styles.button,
{ backgroundColor: isDarkMode ? 'rgba(120,120,128,0.2)' : 'rgba(120,120,128,0.1)' }
]}
disabled={true}
>
<Text style={[
styles.buttonText,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
]}>
Sync Now (Coming Soon)
</Text>
</TouchableOpacity>
</View>
</View>
)}
</ScrollView>
</SafeAreaView>
);
};
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;

View file

@ -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<void> {
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<boolean> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<void> {
if (!this.isInitialized) {
await this.initialize();
}
}
/**
* Make an authenticated API request to Trakt
*/
private async apiRequest<T>(
endpoint: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
body?: any
): Promise<T> {
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<TraktUser> {
return this.apiRequest<TraktUser>('/users/me?extended=full');
}
/**
* Get the user's watched movies
*/
public async getWatchedMovies(): Promise<TraktWatchedItem[]> {
return this.apiRequest<TraktWatchedItem[]>('/sync/watched/movies');
}
/**
* Get the user's watched shows
*/
public async getWatchedShows(): Promise<TraktWatchedItem[]> {
return this.apiRequest<TraktWatchedItem[]>('/sync/watched/shows');
}
/**
* Get trakt id from IMDb id
*/
public async getTraktIdFromImdbId(imdbId: string, type: 'movies' | 'shows'): Promise<number | null> {
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<boolean> {
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<boolean> {
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<boolean> {
try {
if (!this.accessToken) {
return false;
}
const traktId = await this.getTraktIdFromImdbId(imdbId, 'movies');
if (!traktId) {
return false;
}
const response = await this.apiRequest<any[]>(`/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<boolean> {
try {
if (!this.accessToken) {
return false;
}
const traktId = await this.getTraktIdFromImdbId(imdbId, 'shows');
if (!traktId) {
return false;
}
const response = await this.apiRequest<any[]>(
`/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();