mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
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:
parent
62371d4bf5
commit
206204998e
13 changed files with 1368 additions and 17 deletions
23
App.tsx
23
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 {
|
|||
<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
2
assets/trakt-logo.png
Normal 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
70
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
37
src/contexts/TraktContext.tsx
Normal file
37
src/contexts/TraktContext.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
85
src/hooks/usePersistentSeasons.ts
Normal file
85
src/hooks/usePersistentSeasons.ts
Normal 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
|
||||
};
|
||||
}
|
||||
146
src/hooks/useTraktIntegration.ts
Normal file
146
src/hooks/useTraktIntegration.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
485
src/screens/TraktSettingsScreen.tsx
Normal file
485
src/screens/TraktSettingsScreen.tsx
Normal 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;
|
||||
462
src/services/traktService.ts
Normal file
462
src/services/traktService.ts
Normal 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();
|
||||
Loading…
Reference in a new issue