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 */