From 237c1fae3d3bc65254a55562d10a33cc85a009ba Mon Sep 17 00:00:00 2001 From: tapframe Date: Fri, 20 Jun 2025 00:07:46 +0530 Subject: [PATCH] Enhance Trakt integration with support for watchlist, collection, and ratings This update expands the Trakt integration by adding functionality to manage watchlist items, collections, and user ratings. New interfaces for TraktWatchlistItem, TraktCollectionItem, and TraktRatingItem have been introduced, along with corresponding methods in the TraktService to fetch this data. The useTraktIntegration hook has been updated to load all collections, and the LibraryScreen now displays these new categories, improving the overall user experience and content organization. --- src/contexts/TraktContext.tsx | 16 +- src/hooks/useTraktIntegration.ts | 59 ++++- src/screens/LibraryScreen.tsx | 396 +++++++++++++++++++++++++++++-- src/services/traktService.ts | 109 +++++++++ 4 files changed, 563 insertions(+), 17 deletions(-) diff --git a/src/contexts/TraktContext.tsx b/src/contexts/TraktContext.tsx index 01d5b22d..1cc30fef 100644 --- a/src/contexts/TraktContext.tsx +++ b/src/contexts/TraktContext.tsx @@ -1,6 +1,13 @@ import React, { createContext, useContext, ReactNode } from 'react'; import { useTraktIntegration } from '../hooks/useTraktIntegration'; -import { TraktUser, TraktWatchedItem } from '../services/traktService'; +import { + TraktUser, + TraktWatchedItem, + TraktWatchlistItem, + TraktCollectionItem, + TraktRatingItem, + TraktPlaybackItem +} from '../services/traktService'; interface TraktContextProps { isAuthenticated: boolean; @@ -8,9 +15,16 @@ interface TraktContextProps { userProfile: TraktUser | null; watchedMovies: TraktWatchedItem[]; watchedShows: TraktWatchedItem[]; + watchlistMovies: TraktWatchlistItem[]; + watchlistShows: TraktWatchlistItem[]; + collectionMovies: TraktCollectionItem[]; + collectionShows: TraktCollectionItem[]; + continueWatching: TraktPlaybackItem[]; + ratedContent: TraktRatingItem[]; checkAuthStatus: () => Promise; refreshAuthStatus: () => Promise; loadWatchedItems: () => Promise; + loadAllCollections: () => Promise; isMovieWatched: (imdbId: string) => Promise; isEpisodeWatched: (imdbId: string, season: number, episode: number) => Promise; markMovieAsWatched: (imdbId: string, watchedAt?: Date) => Promise; diff --git a/src/hooks/useTraktIntegration.ts b/src/hooks/useTraktIntegration.ts index d19ba3c0..425ac1cd 100644 --- a/src/hooks/useTraktIntegration.ts +++ b/src/hooks/useTraktIntegration.ts @@ -1,6 +1,15 @@ import { useState, useEffect, useCallback } from 'react'; import { AppState, AppStateStatus } from 'react-native'; -import { traktService, TraktUser, TraktWatchedItem, TraktContentData, TraktPlaybackItem } from '../services/traktService'; +import { + traktService, + TraktUser, + TraktWatchedItem, + TraktWatchlistItem, + TraktCollectionItem, + TraktRatingItem, + TraktContentData, + TraktPlaybackItem +} from '../services/traktService'; import { storageService } from '../services/storageService'; import { logger } from '../utils/logger'; @@ -10,6 +19,12 @@ export function useTraktIntegration() { const [userProfile, setUserProfile] = useState(null); const [watchedMovies, setWatchedMovies] = useState([]); const [watchedShows, setWatchedShows] = useState([]); + const [watchlistMovies, setWatchlistMovies] = useState([]); + const [watchlistShows, setWatchlistShows] = useState([]); + const [collectionMovies, setCollectionMovies] = useState([]); + const [collectionShows, setCollectionShows] = useState([]); + const [continueWatching, setContinueWatching] = useState([]); + const [ratedContent, setRatedContent] = useState([]); const [lastAuthCheck, setLastAuthCheck] = useState(Date.now()); // Check authentication status @@ -65,6 +80,41 @@ export function useTraktIntegration() { } }, [isAuthenticated]); + // Load all collections (watchlist, collection, continue watching, ratings) + const loadAllCollections = useCallback(async () => { + if (!isAuthenticated) return; + + setIsLoading(true); + try { + const [ + watchlistMovies, + watchlistShows, + collectionMovies, + collectionShows, + continueWatching, + ratings + ] = await Promise.all([ + traktService.getWatchlistMovies(), + traktService.getWatchlistShows(), + traktService.getCollectionMovies(), + traktService.getCollectionShows(), + traktService.getPlaybackProgress(), + traktService.getRatings() + ]); + + setWatchlistMovies(watchlistMovies); + setWatchlistShows(watchlistShows); + setCollectionMovies(collectionMovies); + setCollectionShows(collectionShows); + setContinueWatching(continueWatching); + setRatedContent(ratings); + } catch (error) { + logger.error('[useTraktIntegration] Error loading all collections:', error); + } finally { + setIsLoading(false); + } + }, [isAuthenticated]); + // Check if a movie is watched const isMovieWatched = useCallback(async (imdbId: string): Promise => { if (!isAuthenticated) return false; @@ -436,8 +486,15 @@ export function useTraktIntegration() { userProfile, watchedMovies, watchedShows, + watchlistMovies, + watchlistShows, + collectionMovies, + collectionShows, + continueWatching, + ratedContent, checkAuthStatus, loadWatchedItems, + loadAllCollections, isMovieWatched, isEpisodeWatched, markMovieAsWatched, diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 50eb5aa4..36513b8f 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { View, Text, @@ -42,12 +42,22 @@ interface TraktDisplayItem { type: 'movie' | 'series'; poster: string; year?: number; - lastWatched: string; - plays: number; + lastWatched?: string; + plays?: number; + rating?: number; imdbId?: string; traktId: number; } +interface TraktFolder { + id: string; + name: string; + icon: keyof typeof MaterialIcons.glyphMap; + description: string; + itemCount: number; + gradient: [string, string]; +} + const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; const SkeletonLoader = () => { @@ -116,6 +126,7 @@ const LibraryScreen = () => { const [libraryItems, setLibraryItems] = useState([]); const [filter, setFilter] = useState<'all' | 'movies' | 'series'>('all'); const [showTraktContent, setShowTraktContent] = useState(false); + const [selectedTraktFolder, setSelectedTraktFolder] = useState(null); const insets = useSafeAreaInsets(); const { currentTheme } = useTheme(); @@ -125,7 +136,14 @@ const LibraryScreen = () => { isLoading: traktLoading, watchedMovies, watchedShows, - loadWatchedItems + watchlistMovies, + watchlistShows, + collectionMovies, + collectionShows, + continueWatching, + ratedContent, + loadWatchedItems, + loadAllCollections } = useTraktContext(); // Force consistent status bar settings @@ -177,6 +195,57 @@ const LibraryScreen = () => { return true; }); + // Generate Trakt collection folders + const traktFolders = useMemo((): TraktFolder[] => { + if (!traktAuthenticated) return []; + + const folders: TraktFolder[] = [ + { + id: 'watched', + name: 'Watched', + icon: 'visibility', + description: 'Your watched content', + itemCount: (watchedMovies?.length || 0) + (watchedShows?.length || 0), + gradient: ['#4CAF50', '#2E7D32'] + }, + { + id: 'continue-watching', + name: 'Continue Watching', + icon: 'play-circle-outline', + description: 'Resume your progress', + itemCount: continueWatching?.length || 0, + gradient: ['#FF9800', '#F57C00'] + }, + { + id: 'watchlist', + name: 'Watchlist', + icon: 'bookmark', + description: 'Want to watch', + itemCount: (watchlistMovies?.length || 0) + (watchlistShows?.length || 0), + gradient: ['#2196F3', '#1976D2'] + }, + { + id: 'collection', + name: 'Collection', + icon: 'library-add', + description: 'Your collection', + itemCount: (collectionMovies?.length || 0) + (collectionShows?.length || 0), + gradient: ['#9C27B0', '#7B1FA2'] + }, + { + id: 'ratings', + name: 'Rated', + icon: 'star', + description: 'Your ratings', + itemCount: ratedContent?.length || 0, + gradient: ['#FF5722', '#D84315'] + } + ]; + + // Only return folders that have content + return folders.filter(folder => folder.itemCount > 0); + }, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]); + // Prepare Trakt items with proper poster URLs const traktItems = useMemo(() => { if (!traktAuthenticated || (!watchedMovies?.length && !watchedShows?.length)) { @@ -226,7 +295,11 @@ const LibraryScreen = () => { } // Sort by last watched date (most recent first) - return items.sort((a, b) => new Date(b.lastWatched).getTime() - new Date(a.lastWatched).getTime()); + return items.sort((a, b) => { + const dateA = a.lastWatched ? new Date(a.lastWatched).getTime() : 0; + const dateB = b.lastWatched ? new Date(b.lastWatched).getTime() : 0; + return dateB - dateA; + }); }, [traktAuthenticated, watchedMovies, watchedShows]); // State for tracking poster URLs @@ -348,6 +421,41 @@ const LibraryScreen = () => { ); + // Render individual Trakt collection folder + const renderTraktCollectionFolder = ({ folder }: { folder: TraktFolder }) => ( + { + setSelectedTraktFolder(folder.id); + loadAllCollections(); // Load all collections when entering a specific folder + }} + activeOpacity={0.7} + > + + + + + {folder.name} + + + {folder.itemCount} items + + + {folder.description} + + + + + ); + const renderTraktFolder = () => ( { navigation.navigate('TraktSettings'); } else { setShowTraktContent(true); + setSelectedTraktFolder(null); // Reset to folder view + loadAllCollections(); // Load all collections when opening } }} activeOpacity={0.7} @@ -427,7 +537,7 @@ const LibraryScreen = () => { Last watched: {item.lastWatched} - {item.plays > 1 && ( + {item.plays && item.plays > 1 && ( {item.plays} plays @@ -446,18 +556,265 @@ const LibraryScreen = () => { ); }; + // Get items for a specific Trakt folder + const getTraktFolderItems = useCallback((folderId: string): TraktDisplayItem[] => { + const items: TraktDisplayItem[] = []; + + switch (folderId) { + case 'watched': + // Add watched movies + if (watchedMovies) { + for (const watchedMovie of watchedMovies) { + const movie = watchedMovie.movie; + if (movie) { + items.push({ + id: String(movie.ids.trakt), + name: movie.title, + type: 'movie', + poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + year: movie.year, + lastWatched: new Date(watchedMovie.last_watched_at).toLocaleDateString(), + plays: watchedMovie.plays, + imdbId: movie.ids.imdb, + traktId: movie.ids.trakt, + }); + } + } + } + // Add watched shows + if (watchedShows) { + for (const watchedShow of watchedShows) { + const show = watchedShow.show; + if (show) { + items.push({ + id: String(show.ids.trakt), + name: show.title, + type: 'series', + poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + year: show.year, + lastWatched: new Date(watchedShow.last_watched_at).toLocaleDateString(), + plays: watchedShow.plays, + imdbId: show.ids.imdb, + traktId: show.ids.trakt, + }); + } + } + } + break; + + case 'continue-watching': + // Add continue watching items + if (continueWatching) { + for (const item of continueWatching) { + if (item.type === 'movie' && item.movie) { + items.push({ + id: String(item.movie.ids.trakt), + name: item.movie.title, + type: 'movie', + poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + year: item.movie.year, + lastWatched: new Date(item.paused_at).toLocaleDateString(), + imdbId: item.movie.ids.imdb, + traktId: item.movie.ids.trakt, + }); + } else if (item.type === 'episode' && item.show && item.episode) { + items.push({ + id: `${item.show.ids.trakt}:${item.episode.season}:${item.episode.number}`, + name: `${item.show.title} S${item.episode.season}E${item.episode.number}`, + type: 'series', + poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + year: item.show.year, + lastWatched: new Date(item.paused_at).toLocaleDateString(), + imdbId: item.show.ids.imdb, + traktId: item.show.ids.trakt, + }); + } + } + } + break; + + case 'watchlist': + // Add watchlist movies + if (watchlistMovies) { + for (const watchlistMovie of watchlistMovies) { + const movie = watchlistMovie.movie; + if (movie) { + items.push({ + id: String(movie.ids.trakt), + name: movie.title, + type: 'movie', + poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + year: movie.year, + lastWatched: new Date(watchlistMovie.listed_at).toLocaleDateString(), + imdbId: movie.ids.imdb, + traktId: movie.ids.trakt, + }); + } + } + } + // Add watchlist shows + if (watchlistShows) { + for (const watchlistShow of watchlistShows) { + const show = watchlistShow.show; + if (show) { + items.push({ + id: String(show.ids.trakt), + name: show.title, + type: 'series', + poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + year: show.year, + lastWatched: new Date(watchlistShow.listed_at).toLocaleDateString(), + imdbId: show.ids.imdb, + traktId: show.ids.trakt, + }); + } + } + } + break; + + case 'collection': + // Add collection movies + if (collectionMovies) { + for (const collectionMovie of collectionMovies) { + const movie = collectionMovie.movie; + if (movie) { + items.push({ + id: String(movie.ids.trakt), + name: movie.title, + type: 'movie', + poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + year: movie.year, + lastWatched: new Date(collectionMovie.collected_at).toLocaleDateString(), + imdbId: movie.ids.imdb, + traktId: movie.ids.trakt, + }); + } + } + } + // Add collection shows + if (collectionShows) { + for (const collectionShow of collectionShows) { + const show = collectionShow.show; + if (show) { + items.push({ + id: String(show.ids.trakt), + name: show.title, + type: 'series', + poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + year: show.year, + lastWatched: new Date(collectionShow.collected_at).toLocaleDateString(), + imdbId: show.ids.imdb, + traktId: show.ids.trakt, + }); + } + } + } + break; + + case 'ratings': + // Add rated content + if (ratedContent) { + for (const ratedItem of ratedContent) { + if (ratedItem.movie) { + const movie = ratedItem.movie; + items.push({ + id: String(movie.ids.trakt), + name: movie.title, + type: 'movie', + poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + year: movie.year, + lastWatched: new Date(ratedItem.rated_at).toLocaleDateString(), + rating: ratedItem.rating, + imdbId: movie.ids.imdb, + traktId: movie.ids.trakt, + }); + } else if (ratedItem.show) { + const show = ratedItem.show; + items.push({ + id: String(show.ids.trakt), + name: show.title, + type: 'series', + poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + year: show.year, + lastWatched: new Date(ratedItem.rated_at).toLocaleDateString(), + rating: ratedItem.rating, + imdbId: show.ids.imdb, + traktId: show.ids.trakt, + }); + } + } + } + break; + } + + // Sort by last watched/added date (most recent first) + return items.sort((a, b) => { + const dateA = a.lastWatched ? new Date(a.lastWatched).getTime() : 0; + const dateB = b.lastWatched ? new Date(b.lastWatched).getTime() : 0; + return dateB - dateA; + }); + }, [watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]); + const renderTraktContent = () => { if (traktLoading) { return ; } - if (traktItems.length === 0) { + // If no specific folder is selected, show the folder structure + if (!selectedTraktFolder) { + if (traktFolders.length === 0) { + return ( + + + No Trakt collections + + Your Trakt collections will appear here once you start using Trakt + + { + loadAllCollections(); + }} + activeOpacity={0.7} + > + Load Collections + + + ); + } + + // Show collection folders + return ( + renderTraktCollectionFolder({ folder: item })} + keyExtractor={item => item.id} + numColumns={2} + contentContainerStyle={styles.listContainer} + showsVerticalScrollIndicator={false} + columnWrapperStyle={styles.columnWrapper} + initialNumToRender={6} + maxToRenderPerBatch={6} + windowSize={5} + removeClippedSubviews={Platform.OS === 'android'} + /> + ); + } + + // Show content for specific folder + const folderItems = getTraktFolderItems(selectedTraktFolder); + + if (folderItems.length === 0) { + const folderName = traktFolders.find(f => f.id === selectedTraktFolder)?.name || 'Collection'; return ( - No watched content + No content in {folderName} - Your Trakt watched history will appear here + This collection is empty { shadowColor: currentTheme.colors.black }]} onPress={() => { - loadWatchedItems(); + loadAllCollections(); }} activeOpacity={0.7} > @@ -475,9 +832,9 @@ const LibraryScreen = () => { ); } - // Separate movies and shows - const movies = traktItems.filter(item => item.type === 'movie'); - const shows = traktItems.filter(item => item.type === 'series'); + // Separate movies and shows for the selected folder + const movies = folderItems.filter(item => item.type === 'movie'); + const shows = folderItems.filter(item => item.type === 'series'); return ( { <> setShowTraktContent(false)} + onPress={() => { + if (selectedTraktFolder) { + setSelectedTraktFolder(null); + } else { + setShowTraktContent(false); + } + }} activeOpacity={0.7} > { - Trakt Collection + {selectedTraktFolder + ? traktFolders.find(f => f.id === selectedTraktFolder)?.name || 'Collection' + : 'Trakt Collection' + } diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 5b3b965c..7aba7f7a 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -47,6 +47,79 @@ export interface TraktWatchedItem { last_watched_at: string; } +export interface TraktWatchlistItem { + 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; + }; + }; + listed_at: string; +} + +export interface TraktCollectionItem { + 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; + }; + }; + collected_at: string; +} + +export interface TraktRatingItem { + 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; + }; + }; + rating: number; + rated_at: string; +} + // New types for scrobbling export interface TraktPlaybackItem { progress: number; @@ -578,6 +651,42 @@ export class TraktService { return this.apiRequest('/sync/watched/shows'); } + /** + * Get the user's watchlist movies + */ + public async getWatchlistMovies(): Promise { + return this.apiRequest('/sync/watchlist/movies'); + } + + /** + * Get the user's watchlist shows + */ + public async getWatchlistShows(): Promise { + return this.apiRequest('/sync/watchlist/shows'); + } + + /** + * Get the user's collection movies + */ + public async getCollectionMovies(): Promise { + return this.apiRequest('/sync/collection/movies'); + } + + /** + * Get the user's collection shows + */ + public async getCollectionShows(): Promise { + return this.apiRequest('/sync/collection/shows'); + } + + /** + * Get the user's ratings + */ + public async getRatings(type?: 'movies' | 'shows'): Promise { + const endpoint = type ? `/sync/ratings/${type}` : '/sync/ratings'; + return this.apiRequest(endpoint); + } + /** * Get trakt id from IMDb id */