diff --git a/.gitignore b/.gitignore index f7c2b632..bf6e7ea0 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ yarn-error.* # typescript *.tsbuildinfo +plan.md +release_announcement.md \ No newline at end of file diff --git a/app.json b/app.json index 453ab6cc..5cb6c500 100644 --- a/app.json +++ b/app.json @@ -41,8 +41,9 @@ }, "android": { "adaptiveIcon": { - "foregroundImage": "./assets/adaptive-icon.png", - "backgroundColor": "#ffffff" + "foregroundImage": "./assets/icon.png", + "backgroundColor": "#020404", + "monochromeImage": "./assets/icon.png" }, "permissions": [ "INTERNET", diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 8e705951..b4cef61d 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { View, Text, @@ -12,6 +12,7 @@ import { Animated as RNAnimated, ActivityIndicator, Platform, + ScrollView, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; @@ -25,13 +26,28 @@ import { RootStackParamList } from '../navigation/AppNavigator'; import { logger } from '../utils/logger'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTheme } from '../contexts/ThemeContext'; +import { useTraktContext } from '../contexts/TraktContext'; +import TraktIcon from '../../assets/rating-icons/trakt.svg'; +import { TMDBService } from '../services/tmdbService'; -// Types +// Define interfaces for proper typing interface LibraryItem extends StreamingContent { progress?: number; lastWatched?: string; } +interface TraktDisplayItem { + id: string; + name: string; + type: 'movie' | 'series'; + poster: string; + year?: number; + lastWatched: string; + plays: number; + imdbId?: string; + traktId: number; +} + const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; const SkeletonLoader = () => { @@ -99,8 +115,18 @@ const LibraryScreen = () => { const [loading, setLoading] = useState(true); const [libraryItems, setLibraryItems] = useState([]); const [filter, setFilter] = useState<'all' | 'movies' | 'series'>('all'); + const [showTraktContent, setShowTraktContent] = useState(false); const insets = useSafeAreaInsets(); const { currentTheme } = useTheme(); + + // Trakt integration + const { + isAuthenticated: traktAuthenticated, + isLoading: traktLoading, + watchedMovies, + watchedShows, + loadWatchedItems + } = useTraktContext(); // Force consistent status bar settings useEffect(() => { @@ -151,6 +177,120 @@ const LibraryScreen = () => { return true; }); + // Prepare Trakt items with proper poster URLs + const traktItems = useMemo(() => { + if (!traktAuthenticated || (!watchedMovies?.length && !watchedShows?.length)) { + return []; + } + + const items: TraktDisplayItem[] = []; + + // Process 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, + }); + } + } + } + + // Process 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, + }); + } + } + } + + // Sort by last watched date (most recent first) + return items.sort((a, b) => new Date(b.lastWatched).getTime() - new Date(a.lastWatched).getTime()); + }, [traktAuthenticated, watchedMovies, watchedShows]); + + // State for tracking poster URLs + const [traktPostersMap, setTraktPostersMap] = useState>(new Map()); + + // Effect to fetch poster URLs for Trakt items + useEffect(() => { + const fetchTraktPosters = async () => { + if (!traktAuthenticated || traktItems.length === 0) return; + + const tmdbService = TMDBService.getInstance(); + + // Process items individually and update state as each poster is fetched + for (const item of traktItems) { + try { + // Get TMDB ID from the original Trakt data + let tmdbId: number | null = null; + + if (item.type === 'movie' && watchedMovies) { + const watchedMovie = watchedMovies.find(wm => wm.movie?.ids.trakt === item.traktId); + tmdbId = watchedMovie?.movie?.ids.tmdb || null; + } else if (item.type === 'series' && watchedShows) { + const watchedShow = watchedShows.find(ws => ws.show?.ids.trakt === item.traktId); + tmdbId = watchedShow?.show?.ids.tmdb || null; + } + + if (tmdbId) { + // Fetch details from TMDB to get poster path + let posterPath: string | null = null; + + if (item.type === 'movie') { + const movieDetails = await tmdbService.getMovieDetails(String(tmdbId)); + posterPath = movieDetails?.poster_path || null; + } else { + const showDetails = await tmdbService.getTVShowDetails(tmdbId); + posterPath = showDetails?.poster_path || null; + } + + if (posterPath) { + const fullPosterUrl = tmdbService.getImageUrl(posterPath, 'w500'); + if (fullPosterUrl) { + // Update state immediately for this item + setTraktPostersMap(prevMap => { + const newMap = new Map(prevMap); + newMap.set(item.id, fullPosterUrl); + return newMap; + }); + } + } + } + } catch (error) { + logger.error(`Failed to fetch poster for Trakt item ${item.id}:`, error); + } + } + }; + + fetchTraktPosters(); + }, [traktItems, traktAuthenticated, watchedMovies, watchedShows]); + + // Log when posters map updates + useEffect(() => { + // Removed debugging logs + }, [traktPostersMap]); + const itemWidth = (width - 48) / 2; // 2 items per row with padding const renderItem = ({ item }: { item: LibraryItem }) => ( @@ -208,8 +348,203 @@ const LibraryScreen = () => { ); + const renderTraktFolder = () => ( + { + if (!traktAuthenticated) { + navigation.navigate('TraktSettings'); + } else { + setShowTraktContent(true); + } + }} + activeOpacity={0.7} + > + + + + + Trakt Collection + + {traktAuthenticated && traktItems.length > 0 && ( + + {traktItems.length} items + + )} + {!traktAuthenticated && ( + + Tap to connect + + )} + + + {/* Trakt badge */} + + + + Trakt + + + + + ); + + const renderTraktItem = ({ item, customWidth }: { item: TraktDisplayItem; customWidth?: number }) => { + const posterUrl = traktPostersMap.get(item.id) || 'https://via.placeholder.com/300x450/ff0000/ffffff?text=No+Poster'; + const width = customWidth || itemWidth; + + return ( + { + // Navigate using IMDB ID for Trakt items + if (item.imdbId) { + navigation.navigate('Metadata', { id: item.imdbId, type: item.type }); + } + }} + activeOpacity={0.7} + > + + + + + {item.name} + + + Last watched: {item.lastWatched} + + {item.plays > 1 && ( + + {item.plays} plays + + )} + + + {/* Trakt badge */} + + + + {item.type === 'movie' ? 'Movie' : 'Series'} + + + + + ); + }; + + const renderTraktContent = () => { + if (traktLoading) { + return ; + } + + if (traktItems.length === 0) { + return ( + + + No watched content + + Your Trakt watched history will appear here + + { + loadWatchedItems(); + }} + activeOpacity={0.7} + > + Refresh + + + ); + } + + // Separate movies and shows + const movies = traktItems.filter(item => item.type === 'movie'); + const shows = traktItems.filter(item => item.type === 'series'); + + return ( + + {movies.length > 0 && ( + + + + + Movies ({movies.length}) + + + + {movies.map((item) => ( + + {renderTraktItem({ item, customWidth: itemWidth * 0.8 })} + + ))} + + + )} + + {shows.length > 0 && ( + + + + + TV Shows ({shows.length}) + + + + {shows.map((item) => ( + + {renderTraktItem({ item, customWidth: itemWidth * 0.8 })} + + ))} + + + )} + + ); + }; + const renderFilter = (filterType: 'all' | 'movies' | 'series', label: string, iconName: keyof typeof MaterialIcons.glyphMap) => { const isActive = filter === filterType; + return ( { ); }; + const renderContent = () => { + if (loading) { + return ; + } + + // Combine regular library items with Trakt folder + const allItems = []; + + // Add Trakt folder if authenticated or as connection prompt + if (traktAuthenticated || !traktAuthenticated) { + allItems.push({ type: 'trakt-folder', id: 'trakt-folder' }); + } + + // Add filtered library items + allItems.push(...filteredItems); + + if (allItems.length === 0) { + return ( + + + Your library is empty + + Add content to your library to keep track of what you're watching + + navigation.navigate('Discover')} + activeOpacity={0.7} + > + Explore Content + + + ); + } + + return ( + { + if (item.type === 'trakt-folder') { + return renderTraktFolder(); + } + return renderItem({ item: item as LibraryItem }); + }} + keyExtractor={item => item.id} + numColumns={2} + contentContainerStyle={styles.listContainer} + showsVerticalScrollIndicator={false} + columnWrapperStyle={styles.columnWrapper} + initialNumToRender={6} + maxToRenderPerBatch={6} + windowSize={5} + removeClippedSubviews={Platform.OS === 'android'} + /> + ); + }; + const headerBaseHeight = Platform.OS === 'android' ? 80 : 60; const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top; const headerHeight = headerBaseHeight + topSpacing; @@ -252,58 +652,48 @@ const LibraryScreen = () => { {/* Header Section with proper top spacing */} - Library + {showTraktContent ? ( + <> + setShowTraktContent(false)} + activeOpacity={0.7} + > + + + + + Trakt Collection + + + + + ) : ( + Library + )} {/* Content Container */} - - {renderFilter('all', 'All', 'apps')} - {renderFilter('movies', 'Movies', 'movie')} - {renderFilter('series', 'TV Shows', 'live-tv')} - - - {loading ? ( - - ) : filteredItems.length === 0 ? ( - - - Your library is empty - - Add content to your library to keep track of what you're watching - - navigation.navigate('Discover')} - activeOpacity={0.7} - > - Explore Content - - - ) : ( - item.id} - numColumns={2} - contentContainerStyle={styles.listContainer} - showsVerticalScrollIndicator={false} - columnWrapperStyle={styles.columnWrapper} - initialNumToRender={6} - maxToRenderPerBatch={6} - windowSize={5} - removeClippedSubviews={Platform.OS === 'android'} - /> + {!showTraktContent && ( + + {renderFilter('all', 'All', 'apps')} + {renderFilter('movies', 'Movies', 'movie')} + {renderFilter('series', 'TV Shows', 'live-tv')} + )} + + {showTraktContent ? renderTraktContent() : renderContent()} @@ -489,7 +879,100 @@ const styles = StyleSheet.create({ exploreButtonText: { fontSize: 16, fontWeight: '600', - } + }, + playsCount: { + fontSize: 11, + color: 'rgba(255,255,255,0.6)', + textShadowColor: 'rgba(0, 0, 0, 0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + marginTop: 2, + }, + filtersScrollView: { + flexGrow: 0, + }, + folderContainer: { + borderRadius: 8, + overflow: 'hidden', + backgroundColor: 'rgba(255,255,255,0.03)', + aspectRatio: 2/3, + elevation: 5, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, + }, + folderGradient: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + padding: 16, + justifyContent: 'center', + alignItems: 'center', + height: '100%', + }, + folderTitle: { + fontSize: 18, + fontWeight: '700', + marginBottom: 4, + textShadowColor: 'rgba(0, 0, 0, 0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + letterSpacing: 0.3, + }, + folderCount: { + fontSize: 12, + color: 'rgba(255,255,255,0.7)', + textShadowColor: 'rgba(0, 0, 0, 0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + }, + folderSubtitle: { + fontSize: 12, + color: 'rgba(255,255,255,0.7)', + textShadowColor: 'rgba(0, 0, 0, 0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + }, + backButton: { + padding: 8, + }, + sectionsContainer: { + flex: 1, + }, + sectionsContent: { + paddingBottom: 90, + }, + section: { + marginBottom: 24, + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + paddingHorizontal: 16, + }, + sectionIcon: { + marginRight: 8, + }, + sectionTitle: { + fontSize: 20, + fontWeight: '700', + letterSpacing: 0.3, + }, + horizontalScrollContent: { + paddingLeft: 16, + paddingRight: 4, + }, + headerTitleContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + headerSpacer: { + width: 44, // Match the back button width + }, }); export default LibraryScreen; \ No newline at end of file diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index ff8ca017..712dea13 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -235,6 +235,11 @@ const SearchScreen = () => { useEffect(() => { loadRecentSearches(); + + // Cleanup function to cancel pending searches on unmount + return () => { + debouncedSearch.cancel(); + }; }, []); const animatedSearchBarStyle = useAnimatedStyle(() => { @@ -299,13 +304,17 @@ const SearchScreen = () => { const saveRecentSearch = async (searchQuery: string) => { try { - const newRecentSearches = [ - searchQuery, - ...recentSearches.filter(s => s !== searchQuery) - ].slice(0, MAX_RECENT_SEARCHES); - - setRecentSearches(newRecentSearches); - await AsyncStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches)); + setRecentSearches(prevSearches => { + const newRecentSearches = [ + searchQuery, + ...prevSearches.filter(s => s !== searchQuery) + ].slice(0, MAX_RECENT_SEARCHES); + + // Save to AsyncStorage + AsyncStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches)); + + return newRecentSearches; + }); } catch (error) { logger.error('Failed to save recent search:', error); } @@ -334,7 +343,7 @@ const SearchScreen = () => { setSearching(false); } }, 800), - [recentSearches] + [] ); useEffect(() => {