From 12d625e8d61b4d0e77ebc66d4d0e0e41a649e190 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 21 Jun 2025 01:43:41 +0530 Subject: [PATCH] Refactor ContentItem and LibraryScreen components for improved state management and performance This update modifies the ContentItem component to eliminate local state management in favor of direct prop usage, enhancing clarity and reducing unnecessary re-renders. Additionally, the LibraryScreen component is refactored to introduce a new TraktItem component for better separation of concerns and improved poster loading logic. The rendering of Trakt items is optimized with a FlatList for better performance, and placeholder handling is implemented for asynchronous poster loading, ensuring a smoother user experience. --- src/components/home/ContentItem.tsx | 42 +-- src/screens/LibraryScreen.tsx | 449 +++++++++------------------- 2 files changed, 156 insertions(+), 335 deletions(-) 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