diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index 860a3012..0483bc27 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -4,7 +4,7 @@ import { Image as ExpoImage } from 'expo-image'; import { MaterialIcons } from '@expo/vector-icons'; import { useTheme } from '../../contexts/ThemeContext'; import { catalogService, StreamingContent } from '../../services/catalogService'; -import DropUpMenu from './DropUpMenu'; +import { DropUpMenu } from './DropUpMenu'; interface ContentItemProps { item: StreamingContent; @@ -53,9 +53,8 @@ const calculatePosterLayout = (screenWidth: number) => { const posterLayout = calculatePosterLayout(width); const POSTER_WIDTH = posterLayout.posterWidth; -const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => { +const ContentItem = React.memo(({ item, onPress }: ContentItemProps) => { const [menuVisible, setMenuVisible] = useState(false); - const [localItem, setLocalItem] = useState(initialItem); const [isWatched, setIsWatched] = useState(false); const [imageLoaded, setImageLoaded] = useState(false); const [imageError, setImageError] = useState(false); @@ -66,16 +65,16 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => { }, []); const handlePress = useCallback(() => { - onPress(localItem.id, localItem.type); - }, [localItem.id, localItem.type, onPress]); + onPress(item.id, item.type); + }, [item.id, item.type, onPress]); const handleOptionSelect = useCallback((option: string) => { switch (option) { case 'library': - if (localItem.inLibrary) { - catalogService.removeFromLibrary(localItem.type, localItem.id); + if (item.inLibrary) { + catalogService.removeFromLibrary(item.type, item.id); } else { - catalogService.addToLibrary(localItem); + catalogService.addToLibrary(item); } break; case 'watched': @@ -86,27 +85,12 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => { case 'share': break; } - }, [localItem]); + }, [item]); const handleMenuClose = useCallback(() => { setMenuVisible(false); }, []); - useEffect(() => { - setLocalItem(initialItem); - }, [initialItem]); - - useEffect(() => { - const unsubscribe = catalogService.subscribeToLibraryUpdates((libraryItems) => { - const isInLibrary = libraryItems.some( - libraryItem => libraryItem.id === localItem.id && libraryItem.type === localItem.type - ); - setLocalItem(prev => ({ ...prev, inLibrary: isInLibrary })); - }); - - return () => unsubscribe(); - }, [localItem.id, localItem.type]); - return ( <> { > { setImageLoaded(false); setImageError(false); @@ -148,7 +132,7 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => { )} - {localItem.inLibrary && ( + {item.inLibrary && ( @@ -159,12 +143,12 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => { ); -}; +}); const styles = StyleSheet.create({ contentItem: { diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 22f61a29..ef80caff 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -28,12 +28,16 @@ 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 { traktService, TraktService } from '../services/traktService'; +import { traktService, TraktService, TraktImages } from '../services/traktService'; // Define interfaces for proper typing interface LibraryItem extends StreamingContent { progress?: number; lastWatched?: string; + gradient: [string, string]; + imdbId?: string; + traktId: number; + images?: TraktImages; } interface TraktDisplayItem { @@ -47,6 +51,7 @@ interface TraktDisplayItem { rating?: number; imdbId?: string; traktId: number; + images?: TraktImages; } interface TraktFolder { @@ -60,6 +65,82 @@ interface TraktFolder { const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; +const TraktItem = React.memo(({ item, width, navigation, currentTheme }: { item: TraktDisplayItem; width: number; navigation: any; currentTheme: any }) => { + const [posterUrl, setPosterUrl] = useState(null); + + useEffect(() => { + let isMounted = true; + const fetchPoster = async () => { + if (item.images) { + const url = await TraktService.getTraktPosterUrlCached(item.images); + if (isMounted && url) { + setPosterUrl(url); + } + } + }; + fetchPoster(); + return () => { isMounted = false; }; + }, [item.images]); + + const handlePress = useCallback(() => { + if (item.imdbId) { + navigation.navigate('Metadata', { id: item.imdbId, type: item.type }); + } + }, [navigation, item.imdbId, item.type]); + + return ( + + + {posterUrl ? ( + + ) : ( + + + + )} + + + {item.name} + + {item.lastWatched && ( + + Last watched: {item.lastWatched} + + )} + {item.plays && item.plays > 1 && ( + + {item.plays} plays + + )} + + + + + + {item.type === 'movie' ? 'Movie' : 'Series'} + + + + + ); +}); + const SkeletonLoader = () => { const pulseAnim = React.useRef(new RNAnimated.Value(0)).current; const { width } = useWindowDimensions(); @@ -168,7 +249,7 @@ const LibraryScreen = () => { setLoading(true); try { const items = await catalogService.getLibraryItems(); - setLibraryItems(items); + setLibraryItems(items as LibraryItem[]); } catch (error) { logger.error('Failed to load library:', error); } finally { @@ -180,7 +261,7 @@ const LibraryScreen = () => { // Subscribe to library updates const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => { - setLibraryItems(items); + setLibraryItems(items as LibraryItem[]); }); return () => { @@ -246,136 +327,6 @@ const LibraryScreen = () => { return folders.filter(folder => folder.itemCount > 0); }, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]); - // State for poster URLs (since they're now async) - const [traktPostersMap, setTraktPostersMap] = useState>(new Map()); - - // Prepare Trakt items with placeholders, then load posters async - 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) { - const itemId = String(movie.ids.trakt); - const cachedPoster = traktPostersMap.get(itemId); - - items.push({ - id: itemId, - name: movie.title, - type: 'movie', - poster: cachedPoster || '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) { - const itemId = String(show.ids.trakt); - const cachedPoster = traktPostersMap.get(itemId); - - items.push({ - id: itemId, - name: show.title, - type: 'series', - poster: cachedPoster || '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) => { - 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, traktPostersMap]); - - // Effect to load cached poster URLs - useEffect(() => { - const loadCachedPosters = async () => { - if (!traktAuthenticated) return; - - const postersToLoad = new Map(); - - // Collect movies that need posters - if (watchedMovies) { - for (const watchedMovie of watchedMovies) { - const movie = watchedMovie.movie; - if (movie) { - const itemId = String(movie.ids.trakt); - if (!traktPostersMap.has(itemId)) { - postersToLoad.set(itemId, movie.images); - } - } - } - } - - // Collect shows that need posters - if (watchedShows) { - for (const watchedShow of watchedShows) { - const show = watchedShow.show; - if (show) { - const itemId = String(show.ids.trakt); - if (!traktPostersMap.has(itemId)) { - postersToLoad.set(itemId, show.images); - } - } - } - } - - // Load posters in parallel - const posterPromises = Array.from(postersToLoad.entries()).map(async ([itemId, images]) => { - try { - const posterUrl = await TraktService.getTraktPosterUrl(images); - return { - itemId, - posterUrl: posterUrl || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster' - }; - } catch (error) { - logger.error(`Failed to get cached poster for ${itemId}:`, error); - return { - itemId, - posterUrl: 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster' - }; - } - }); - - const results = await Promise.all(posterPromises); - - // Update state with new posters - setTraktPostersMap(prevMap => { - const newMap = new Map(prevMap); - results.forEach(({ itemId, posterUrl }) => { - newMap.set(itemId, posterUrl); - }); - return newMap; - }); - }; - - loadCachedPosters(); - }, [traktAuthenticated, watchedMovies, watchedShows]); - const itemWidth = (width - 48) / 2; // 2 items per row with padding const renderItem = ({ item }: { item: LibraryItem }) => ( @@ -491,9 +442,9 @@ const LibraryScreen = () => { Trakt Collection - {traktAuthenticated && traktItems.length > 0 && ( + {traktAuthenticated && traktFolders.length > 0 && ( - {traktItems.length} items + {traktFolders.length} items )} {!traktAuthenticated && ( @@ -514,59 +465,9 @@ const LibraryScreen = () => { ); - const renderTraktItem = ({ item, customWidth }: { item: TraktDisplayItem; customWidth?: number }) => { - const posterUrl = item.poster || '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 && item.plays > 1 && ( - - {item.plays} plays - - )} - - - {/* Trakt badge */} - - - - {item.type === 'movie' ? 'Movie' : 'Series'} - - - - - ); - }; + const renderTraktItem = useCallback(({ item }: { item: TraktDisplayItem }) => { + return ; + }, [itemWidth, navigation, currentTheme]); // Get items for a specific Trakt folder const getTraktFolderItems = useCallback((folderId: string): TraktDisplayItem[] => { @@ -579,19 +480,17 @@ const LibraryScreen = () => { for (const watchedMovie of watchedMovies) { const movie = watchedMovie.movie; if (movie) { - const itemId = String(movie.ids.trakt); - const cachedPoster = traktPostersMap.get(itemId); - items.push({ - id: itemId, + id: String(movie.ids.trakt), name: movie.title, type: 'movie', - poster: cachedPoster || 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + poster: 'placeholder', year: movie.year, lastWatched: new Date(watchedMovie.last_watched_at).toLocaleDateString(), plays: watchedMovie.plays, imdbId: movie.ids.imdb, traktId: movie.ids.trakt, + images: movie.images, }); } } @@ -601,19 +500,17 @@ const LibraryScreen = () => { for (const watchedShow of watchedShows) { const show = watchedShow.show; if (show) { - const itemId = String(show.ids.trakt); - const cachedPoster = traktPostersMap.get(itemId); - items.push({ - id: itemId, + id: String(show.ids.trakt), name: show.title, type: 'series', - poster: cachedPoster || 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + poster: 'placeholder', year: show.year, lastWatched: new Date(watchedShow.last_watched_at).toLocaleDateString(), plays: watchedShow.plays, imdbId: show.ids.imdb, traktId: show.ids.trakt, + images: show.images, }); } } @@ -625,32 +522,28 @@ const LibraryScreen = () => { if (continueWatching) { for (const item of continueWatching) { if (item.type === 'movie' && item.movie) { - const itemId = String(item.movie.ids.trakt); - const cachedPoster = traktPostersMap.get(itemId); - items.push({ - id: itemId, + id: String(item.movie.ids.trakt), name: item.movie.title, type: 'movie', - poster: cachedPoster || 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + poster: 'placeholder', year: item.movie.year, lastWatched: new Date(item.paused_at).toLocaleDateString(), imdbId: item.movie.ids.imdb, traktId: item.movie.ids.trakt, + images: item.movie.images, }); } else if (item.type === 'episode' && item.show && item.episode) { - const itemId = String(item.show.ids.trakt); - const cachedPoster = traktPostersMap.get(itemId); - 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: cachedPoster || 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + poster: 'placeholder', year: item.show.year, lastWatched: new Date(item.paused_at).toLocaleDateString(), imdbId: item.show.ids.imdb, traktId: item.show.ids.trakt, + images: item.show.images, }); } } @@ -663,19 +556,16 @@ const LibraryScreen = () => { for (const watchlistMovie of watchlistMovies) { const movie = watchlistMovie.movie; if (movie) { - const itemId = String(movie.ids.trakt); - const posterUrl = TraktService.getTraktPosterUrl(movie.images) || - 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster'; - items.push({ - id: itemId, + id: String(movie.ids.trakt), name: movie.title, type: 'movie', - poster: posterUrl, + poster: 'placeholder', year: movie.year, lastWatched: new Date(watchlistMovie.listed_at).toLocaleDateString(), imdbId: movie.ids.imdb, traktId: movie.ids.trakt, + images: movie.images, }); } } @@ -685,19 +575,16 @@ const LibraryScreen = () => { for (const watchlistShow of watchlistShows) { const show = watchlistShow.show; if (show) { - const itemId = String(show.ids.trakt); - const posterUrl = TraktService.getTraktPosterUrl(show.images) || - 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster'; - items.push({ - id: itemId, + id: String(show.ids.trakt), name: show.title, type: 'series', - poster: posterUrl, + poster: 'placeholder', year: show.year, lastWatched: new Date(watchlistShow.listed_at).toLocaleDateString(), imdbId: show.ids.imdb, traktId: show.ids.trakt, + images: show.images, }); } } @@ -710,19 +597,16 @@ const LibraryScreen = () => { for (const collectionMovie of collectionMovies) { const movie = collectionMovie.movie; if (movie) { - const itemId = String(movie.ids.trakt); - const posterUrl = TraktService.getTraktPosterUrl(movie.images) || - 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster'; - items.push({ - id: itemId, + id: String(movie.ids.trakt), name: movie.title, type: 'movie', - poster: posterUrl, + poster: 'placeholder', year: movie.year, lastWatched: new Date(collectionMovie.collected_at).toLocaleDateString(), imdbId: movie.ids.imdb, traktId: movie.ids.trakt, + images: movie.images, }); } } @@ -732,19 +616,16 @@ const LibraryScreen = () => { for (const collectionShow of collectionShows) { const show = collectionShow.show; if (show) { - const itemId = String(show.ids.trakt); - const posterUrl = TraktService.getTraktPosterUrl(show.images) || - 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster'; - items.push({ - id: itemId, + id: String(show.ids.trakt), name: show.title, type: 'series', - poster: posterUrl, + poster: 'placeholder', year: show.year, lastWatched: new Date(collectionShow.collected_at).toLocaleDateString(), imdbId: show.ids.imdb, traktId: show.ids.trakt, + images: show.images, }); } } @@ -757,37 +638,31 @@ const LibraryScreen = () => { for (const ratedItem of ratedContent) { if (ratedItem.movie) { const movie = ratedItem.movie; - const itemId = String(movie.ids.trakt); - const posterUrl = TraktService.getTraktPosterUrl(movie.images) || - 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster'; - items.push({ - id: itemId, + id: String(movie.ids.trakt), name: movie.title, type: 'movie', - poster: posterUrl, + poster: 'placeholder', year: movie.year, lastWatched: new Date(ratedItem.rated_at).toLocaleDateString(), rating: ratedItem.rating, imdbId: movie.ids.imdb, traktId: movie.ids.trakt, + images: movie.images, }); } else if (ratedItem.show) { const show = ratedItem.show; - const itemId = String(show.ids.trakt); - const posterUrl = TraktService.getTraktPosterUrl(show.images) || - 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster'; - items.push({ - id: itemId, + id: String(show.ids.trakt), name: show.title, type: 'series', - poster: posterUrl, + poster: 'placeholder', year: show.year, lastWatched: new Date(ratedItem.rated_at).toLocaleDateString(), rating: ratedItem.rating, imdbId: show.ids.imdb, traktId: show.ids.trakt, + images: show.images, }); } } @@ -801,7 +676,7 @@ const LibraryScreen = () => { const dateB = b.lastWatched ? new Date(b.lastWatched).getTime() : 0; return dateB - dateA; }); - }, [watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent, traktPostersMap]); + }, [watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]); const renderTraktContent = () => { if (traktLoading) { @@ -880,70 +755,21 @@ const LibraryScreen = () => { ); } - // 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 ( - renderTraktItem({ item })} + keyExtractor={(item) => `${item.type}-${item.id}`} + numColumns={2} + columnWrapperStyle={styles.row} + style={styles.traktContainer} + contentContainerStyle={{ paddingBottom: insets.bottom + 80 }} showsVerticalScrollIndicator={false} - contentContainerStyle={styles.sectionsContent} - > - {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 })} - - ))} - - - )} - + initialNumToRender={10} + maxToRenderPerBatch={10} + windowSize={21} + removeClippedSubviews={Platform.OS === 'android'} + /> ); }; @@ -1387,6 +1213,17 @@ const styles = StyleSheet.create({ headerSpacer: { width: 44, // Match the back button width }, + traktContainer: { + flex: 1, + }, + emptyListText: { + fontSize: 16, + fontWeight: '500', + }, + row: { + justifyContent: 'space-between', + paddingHorizontal: 16, + }, }); export default LibraryScreen; \ No newline at end of file