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 'react-native-reanimated';
|
||||||
import { CatalogProvider } from './src/contexts/CatalogContext';
|
import { CatalogProvider } from './src/contexts/CatalogContext';
|
||||||
import { GenreProvider } from './src/contexts/GenreContext';
|
import { GenreProvider } from './src/contexts/GenreContext';
|
||||||
|
import { TraktProvider } from './src/contexts/TraktContext';
|
||||||
|
|
||||||
function App(): React.JSX.Element {
|
function App(): React.JSX.Element {
|
||||||
// Always use dark mode
|
// Always use dark mode
|
||||||
|
|
@ -30,16 +31,18 @@ function App(): React.JSX.Element {
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<GenreProvider>
|
<GenreProvider>
|
||||||
<CatalogProvider>
|
<CatalogProvider>
|
||||||
<PaperProvider theme={CustomDarkTheme}>
|
<TraktProvider>
|
||||||
<NavigationContainer theme={CustomNavigationDarkTheme}>
|
<PaperProvider theme={CustomDarkTheme}>
|
||||||
<View style={[styles.container, { backgroundColor: '#000000' }]}>
|
<NavigationContainer theme={CustomNavigationDarkTheme}>
|
||||||
<StatusBar
|
<View style={[styles.container, { backgroundColor: '#000000' }]}>
|
||||||
style="light"
|
<StatusBar
|
||||||
/>
|
style="light"
|
||||||
<AppNavigator />
|
/>
|
||||||
</View>
|
<AppNavigator />
|
||||||
</NavigationContainer>
|
</View>
|
||||||
</PaperProvider>
|
</NavigationContainer>
|
||||||
|
</PaperProvider>
|
||||||
|
</TraktProvider>
|
||||||
</CatalogProvider>
|
</CatalogProvider>
|
||||||
</GenreProvider>
|
</GenreProvider>
|
||||||
</GestureHandlerRootView>
|
</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",
|
"axios": "^1.8.4",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"expo": "~52.0.43",
|
"expo": "~52.0.43",
|
||||||
|
"expo-auth-session": "^6.0.3",
|
||||||
"expo-blur": "^14.0.3",
|
"expo-blur": "^14.0.3",
|
||||||
"expo-file-system": "^18.0.12",
|
"expo-file-system": "^18.0.12",
|
||||||
"expo-haptics": "~14.0.1",
|
"expo-haptics": "~14.0.1",
|
||||||
|
|
@ -31,9 +32,11 @@
|
||||||
"expo-intent-launcher": "~12.0.2",
|
"expo-intent-launcher": "~12.0.2",
|
||||||
"expo-linear-gradient": "~14.0.2",
|
"expo-linear-gradient": "~14.0.2",
|
||||||
"expo-notifications": "~0.29.14",
|
"expo-notifications": "~0.29.14",
|
||||||
|
"expo-random": "^14.0.1",
|
||||||
"expo-screen-orientation": "~8.0.4",
|
"expo-screen-orientation": "~8.0.4",
|
||||||
"expo-status-bar": "~2.0.1",
|
"expo-status-bar": "~2.0.1",
|
||||||
"expo-system-ui": "^4.0.9",
|
"expo-system-ui": "^4.0.9",
|
||||||
|
"expo-web-browser": "^14.0.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-native": "0.76.9",
|
"react-native": "0.76.9",
|
||||||
|
|
@ -6630,6 +6633,24 @@
|
||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-blur": {
|
||||||
"version": "14.0.3",
|
"version": "14.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-14.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-14.0.3.tgz",
|
||||||
|
|
@ -6655,6 +6676,18 @@
|
||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-file-system": {
|
||||||
"version": "18.0.12",
|
"version": "18.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.0.12.tgz",
|
||||||
|
|
@ -6737,6 +6770,20 @@
|
||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-modules-autolinking": {
|
||||||
"version": "2.0.8",
|
"version": "2.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-2.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-2.0.8.tgz",
|
||||||
|
|
@ -6821,6 +6868,19 @@
|
||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-screen-orientation": {
|
||||||
"version": "8.0.4",
|
"version": "8.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/expo-screen-orientation/-/expo-screen-orientation-8.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/expo-screen-orientation/-/expo-screen-orientation-8.0.4.tgz",
|
||||||
|
|
@ -6867,6 +6927,16 @@
|
||||||
"integrity": "sha512-FRjRvs7RgsXjkbGSOjYSxhX5V70c0IzA/jy3HXeYpATMwD9fOR1DbveLW497QGsVdCa0vThbJUtR8rIzAfpHQA==",
|
"integrity": "sha512-FRjRvs7RgsXjkbGSOjYSxhX5V70c0IzA/jy3HXeYpATMwD9fOR1DbveLW497QGsVdCa0vThbJUtR8rIzAfpHQA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/exponential-backoff": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.8.4",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"expo": "~52.0.43",
|
"expo": "~52.0.43",
|
||||||
|
"expo-auth-session": "^6.0.3",
|
||||||
"expo-blur": "^14.0.3",
|
"expo-blur": "^14.0.3",
|
||||||
"expo-file-system": "^18.0.12",
|
"expo-file-system": "^18.0.12",
|
||||||
"expo-haptics": "~14.0.1",
|
"expo-haptics": "~14.0.1",
|
||||||
|
|
@ -32,9 +33,11 @@
|
||||||
"expo-intent-launcher": "~12.0.2",
|
"expo-intent-launcher": "~12.0.2",
|
||||||
"expo-linear-gradient": "~14.0.2",
|
"expo-linear-gradient": "~14.0.2",
|
||||||
"expo-notifications": "~0.29.14",
|
"expo-notifications": "~0.29.14",
|
||||||
|
"expo-random": "^14.0.1",
|
||||||
"expo-screen-orientation": "~8.0.4",
|
"expo-screen-orientation": "~8.0.4",
|
||||||
"expo-status-bar": "~2.0.1",
|
"expo-status-bar": "~2.0.1",
|
||||||
"expo-system-ui": "^4.0.9",
|
"expo-system-ui": "^4.0.9",
|
||||||
|
"expo-web-browser": "^14.0.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-native": "0.76.9",
|
"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 { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme } from 'react-native';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
||||||
|
|
@ -37,6 +37,9 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
const isTablet = width > 768;
|
const isTablet = width > 768;
|
||||||
const isDarkMode = useColorScheme() === 'dark';
|
const isDarkMode = useColorScheme() === 'dark';
|
||||||
const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: { currentTime: number; duration: number } }>({});
|
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 () => {
|
const loadEpisodesProgress = async () => {
|
||||||
if (!metadata?.id) return;
|
if (!metadata?.id) return;
|
||||||
|
|
@ -70,6 +73,25 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
}, [episodes, metadata?.id])
|
}, [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) {
|
if (loadingSeasons) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centeredContainer}>
|
<View style={styles.centeredContainer}>
|
||||||
|
|
@ -99,6 +121,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
<View style={styles.seasonSelectorWrapper}>
|
<View style={styles.seasonSelectorWrapper}>
|
||||||
<Text style={styles.seasonSelectorTitle}>Seasons</Text>
|
<Text style={styles.seasonSelectorTitle}>Seasons</Text>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
ref={seasonScrollViewRef}
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
style={styles.seasonSelectorContainer}
|
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 { Cast, Episode, GroupedEpisodes, GroupedStreams } from '../types/metadata';
|
||||||
import { TMDBService } from '../services/tmdbService';
|
import { TMDBService } from '../services/tmdbService';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
import { usePersistentSeasons } from './usePersistentSeasons';
|
||||||
|
|
||||||
// Constants for timeouts and retries
|
// Constants for timeouts and retries
|
||||||
const API_TIMEOUT = 10000; // 10 seconds
|
const API_TIMEOUT = 10000; // 10 seconds
|
||||||
|
|
@ -113,6 +114,9 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
|
||||||
const [loadingRecommendations, setLoadingRecommendations] = useState(false);
|
const [loadingRecommendations, setLoadingRecommendations] = useState(false);
|
||||||
const [imdbId, setImdbId] = useState<string | null>(null);
|
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 processStremioSource = async (type: string, id: string, isEpisode = false) => {
|
||||||
const sourceStartTime = Date.now();
|
const sourceStartTime = Date.now();
|
||||||
const logPrefix = isEpisode ? 'loadEpisodeStreams' : 'loadStreams';
|
const logPrefix = isEpisode ? 'loadEpisodeStreams' : 'loadStreams';
|
||||||
|
|
@ -575,10 +579,17 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
|
||||||
|
|
||||||
setGroupedEpisodes(transformedEpisodes);
|
setGroupedEpisodes(transformedEpisodes);
|
||||||
|
|
||||||
|
// Get the first available season as fallback
|
||||||
const firstSeason = Math.min(...Object.keys(allEpisodes).map(Number));
|
const firstSeason = Math.min(...Object.keys(allEpisodes).map(Number));
|
||||||
const initialEpisodes = transformedEpisodes[firstSeason] || [];
|
|
||||||
setSelectedSeason(firstSeason);
|
// Get saved season from persistence, fallback to first season if not found
|
||||||
setEpisodes(initialEpisodes);
|
const persistedSeason = getSeason(id, firstSeason);
|
||||||
|
|
||||||
|
// Set the selected season from persistence
|
||||||
|
setSelectedSeason(persistedSeason);
|
||||||
|
|
||||||
|
// Set episodes for the selected season
|
||||||
|
setEpisodes(transformedEpisodes[persistedSeason] || []);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load episodes:', error);
|
console.error('Failed to load episodes:', error);
|
||||||
|
|
@ -958,9 +969,14 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
|
||||||
|
|
||||||
const handleSeasonChange = useCallback((seasonNumber: number) => {
|
const handleSeasonChange = useCallback((seasonNumber: number) => {
|
||||||
if (selectedSeason === seasonNumber) return;
|
if (selectedSeason === seasonNumber) return;
|
||||||
|
|
||||||
|
// Update local state
|
||||||
setSelectedSeason(seasonNumber);
|
setSelectedSeason(seasonNumber);
|
||||||
setEpisodes(groupedEpisodes[seasonNumber] || []);
|
setEpisodes(groupedEpisodes[seasonNumber] || []);
|
||||||
}, [selectedSeason, groupedEpisodes]);
|
|
||||||
|
// Persist the selection
|
||||||
|
saveSeason(id, seasonNumber);
|
||||||
|
}, [selectedSeason, groupedEpisodes, saveSeason, id]);
|
||||||
|
|
||||||
const toggleLibrary = useCallback(() => {
|
const toggleLibrary = useCallback(() => {
|
||||||
if (!metadata) return;
|
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 TMDBSettingsScreen from '../screens/TMDBSettingsScreen';
|
||||||
import HomeScreenSettings from '../screens/HomeScreenSettings';
|
import HomeScreenSettings from '../screens/HomeScreenSettings';
|
||||||
import HeroCatalogsScreen from '../screens/HeroCatalogsScreen';
|
import HeroCatalogsScreen from '../screens/HeroCatalogsScreen';
|
||||||
|
import TraktSettingsScreen from '../screens/TraktSettingsScreen';
|
||||||
|
|
||||||
// Stack navigator types
|
// Stack navigator types
|
||||||
export type RootStackParamList = {
|
export type RootStackParamList = {
|
||||||
|
|
@ -85,6 +86,7 @@ export type RootStackParamList = {
|
||||||
TMDBSettings: undefined;
|
TMDBSettings: undefined;
|
||||||
HomeScreenSettings: undefined;
|
HomeScreenSettings: undefined;
|
||||||
HeroCatalogs: undefined;
|
HeroCatalogs: undefined;
|
||||||
|
TraktSettings: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
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>
|
</Stack.Navigator>
|
||||||
</PaperProvider>
|
</PaperProvider>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { useSettings, DEFAULT_SETTINGS } from '../hooks/useSettings';
|
||||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
import { stremioService } from '../services/stremioService';
|
import { stremioService } from '../services/stremioService';
|
||||||
import { useCatalogContext } from '../contexts/CatalogContext';
|
import { useCatalogContext } from '../contexts/CatalogContext';
|
||||||
|
import { useTraktContext } from '../contexts/TraktContext';
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
|
|
@ -111,6 +112,7 @@ const SettingsScreen: React.FC = () => {
|
||||||
const isDarkMode = systemColorScheme === 'dark' || settings.enableDarkMode;
|
const isDarkMode = systemColorScheme === 'dark' || settings.enableDarkMode;
|
||||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
const { lastUpdate } = useCatalogContext();
|
const { lastUpdate } = useCatalogContext();
|
||||||
|
const { isAuthenticated, userProfile } = useTraktContext();
|
||||||
|
|
||||||
// States for dynamic content
|
// States for dynamic content
|
||||||
const [addonCount, setAddonCount] = useState<number>(0);
|
const [addonCount, setAddonCount] = useState<number>(0);
|
||||||
|
|
@ -225,11 +227,11 @@ const SettingsScreen: React.FC = () => {
|
||||||
<SettingsCard isDarkMode={isDarkMode}>
|
<SettingsCard isDarkMode={isDarkMode}>
|
||||||
<SettingItem
|
<SettingItem
|
||||||
title="Trakt"
|
title="Trakt"
|
||||||
description="Not Connected"
|
description={isAuthenticated ? `Connected as ${userProfile?.username || 'User'}` : "Not Connected"}
|
||||||
icon="person"
|
icon="person"
|
||||||
isDarkMode={isDarkMode}
|
isDarkMode={isDarkMode}
|
||||||
renderControl={ChevronRight}
|
renderControl={ChevronRight}
|
||||||
onPress={() => Alert.alert('Trakt', 'Trakt integration coming soon')}
|
onPress={() => navigation.navigate('TraktSettings')}
|
||||||
/>
|
/>
|
||||||
<SettingItem
|
<SettingItem
|
||||||
title="iCloud Sync"
|
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