added trakt watch history.

This commit is contained in:
tapframe 2025-06-18 21:47:19 +05:30
parent 70586e2b64
commit 6acf84677f
4 changed files with 554 additions and 59 deletions

2
.gitignore vendored
View file

@ -36,3 +36,5 @@ yarn-error.*
# typescript
*.tsbuildinfo
plan.md
release_announcement.md

View file

@ -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",

View file

@ -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<LibraryItem[]>([]);
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<Map<string, string>>(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 = () => {
</TouchableOpacity>
);
const renderTraktFolder = () => (
<TouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]}
onPress={() => {
if (!traktAuthenticated) {
navigation.navigate('TraktSettings');
} else {
setShowTraktContent(true);
}
}}
activeOpacity={0.7}
>
<View style={[styles.posterContainer, styles.folderContainer, { shadowColor: currentTheme.colors.black }]}>
<LinearGradient
colors={['#E8254B', '#C41E3A']}
style={styles.folderGradient}
>
<TraktIcon width={60} height={60} style={{ marginBottom: 12 }} />
<Text style={[styles.folderTitle, { color: currentTheme.colors.white }]}>
Trakt Collection
</Text>
{traktAuthenticated && traktItems.length > 0 && (
<Text style={styles.folderCount}>
{traktItems.length} items
</Text>
)}
{!traktAuthenticated && (
<Text style={styles.folderSubtitle}>
Tap to connect
</Text>
)}
</LinearGradient>
{/* Trakt badge */}
<View style={[styles.badgeContainer, { backgroundColor: 'rgba(255,255,255,0.2)' }]}>
<TraktIcon width={12} height={12} style={{ marginRight: 4 }} />
<Text style={[styles.badgeText, { color: currentTheme.colors.white }]}>
Trakt
</Text>
</View>
</View>
</TouchableOpacity>
);
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 (
<TouchableOpacity
style={[styles.itemContainer, { width }]}
onPress={() => {
// Navigate using IMDB ID for Trakt items
if (item.imdbId) {
navigation.navigate('Metadata', { id: item.imdbId, type: item.type });
}
}}
activeOpacity={0.7}
>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
<Image
source={{ uri: posterUrl }}
style={styles.poster}
contentFit="cover"
transition={300}
/>
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.85)']}
style={styles.posterGradient}
>
<Text
style={[styles.itemTitle, { color: currentTheme.colors.white }]}
numberOfLines={2}
>
{item.name}
</Text>
<Text style={styles.lastWatched}>
Last watched: {item.lastWatched}
</Text>
{item.plays > 1 && (
<Text style={styles.playsCount}>
{item.plays} plays
</Text>
)}
</LinearGradient>
{/* Trakt badge */}
<View style={[styles.badgeContainer, { backgroundColor: 'rgba(232,37,75,0.9)' }]}>
<TraktIcon width={12} height={12} style={{ marginRight: 4 }} />
<Text style={[styles.badgeText, { color: currentTheme.colors.white }]}>
{item.type === 'movie' ? 'Movie' : 'Series'}
</Text>
</View>
</View>
</TouchableOpacity>
);
};
const renderTraktContent = () => {
if (traktLoading) {
return <SkeletonLoader />;
}
if (traktItems.length === 0) {
return (
<View style={styles.emptyContainer}>
<TraktIcon width={80} height={80} style={{ opacity: 0.7, marginBottom: 16 }} />
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>No watched content</Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}>
Your Trakt watched history will appear here
</Text>
<TouchableOpacity
style={[styles.exploreButton, {
backgroundColor: currentTheme.colors.primary,
shadowColor: currentTheme.colors.black
}]}
onPress={() => {
loadWatchedItems();
}}
activeOpacity={0.7}
>
<Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>Refresh</Text>
</TouchableOpacity>
</View>
);
}
// Separate movies and shows
const movies = traktItems.filter(item => item.type === 'movie');
const shows = traktItems.filter(item => item.type === 'series');
return (
<ScrollView
style={styles.sectionsContainer}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.sectionsContent}
>
{movies.length > 0 && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<MaterialIcons
name="movie"
size={24}
color={currentTheme.colors.white}
style={styles.sectionIcon}
/>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.white }]}>
Movies ({movies.length})
</Text>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalScrollContent}
>
{movies.map((item) => (
<View key={item.id} style={{ width: itemWidth * 0.8, marginRight: 12 }}>
{renderTraktItem({ item, customWidth: itemWidth * 0.8 })}
</View>
))}
</ScrollView>
</View>
)}
{shows.length > 0 && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<MaterialIcons
name="live-tv"
size={24}
color={currentTheme.colors.white}
style={styles.sectionIcon}
/>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.white }]}>
TV Shows ({shows.length})
</Text>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalScrollContent}
>
{shows.map((item) => (
<View key={item.id} style={{ width: itemWidth * 0.8, marginRight: 12 }}>
{renderTraktItem({ item, customWidth: itemWidth * 0.8 })}
</View>
))}
</ScrollView>
</View>
)}
</ScrollView>
);
};
const renderFilter = (filterType: 'all' | 'movies' | 'series', label: string, iconName: keyof typeof MaterialIcons.glyphMap) => {
const isActive = filter === filterType;
return (
<TouchableOpacity
style={[
@ -239,6 +574,71 @@ const LibraryScreen = () => {
);
};
const renderContent = () => {
if (loading) {
return <SkeletonLoader />;
}
// 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 (
<View style={styles.emptyContainer}>
<MaterialIcons
name="video-library"
size={80}
color={currentTheme.colors.mediumGray}
style={{ opacity: 0.7 }}
/>
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>Your library is empty</Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}>
Add content to your library to keep track of what you're watching
</Text>
<TouchableOpacity
style={[styles.exploreButton, {
backgroundColor: currentTheme.colors.primary,
shadowColor: currentTheme.colors.black
}]}
onPress={() => navigation.navigate('Discover')}
activeOpacity={0.7}
>
<Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>Explore Content</Text>
</TouchableOpacity>
</View>
);
}
return (
<FlatList
data={allItems}
renderItem={({ item }) => {
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 */}
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
<View style={styles.headerContent}>
<Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>Library</Text>
{showTraktContent ? (
<>
<TouchableOpacity
style={styles.backButton}
onPress={() => setShowTraktContent(false)}
activeOpacity={0.7}
>
<MaterialIcons
name="arrow-back"
size={28}
color={currentTheme.colors.white}
/>
</TouchableOpacity>
<View style={styles.headerTitleContainer}>
<Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>
Trakt Collection
</Text>
</View>
<View style={styles.headerSpacer} />
</>
) : (
<Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>Library</Text>
)}
</View>
</View>
{/* Content Container */}
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
<View style={styles.filtersContainer}>
{renderFilter('all', 'All', 'apps')}
{renderFilter('movies', 'Movies', 'movie')}
{renderFilter('series', 'TV Shows', 'live-tv')}
</View>
{loading ? (
<SkeletonLoader />
) : filteredItems.length === 0 ? (
<View style={styles.emptyContainer}>
<MaterialIcons
name="video-library"
size={80}
color={currentTheme.colors.mediumGray}
style={{ opacity: 0.7 }}
/>
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>Your library is empty</Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}>
Add content to your library to keep track of what you're watching
</Text>
<TouchableOpacity
style={[styles.exploreButton, {
backgroundColor: currentTheme.colors.primary,
shadowColor: currentTheme.colors.black
}]}
onPress={() => navigation.navigate('Discover')}
activeOpacity={0.7}
>
<Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>Explore Content</Text>
</TouchableOpacity>
</View>
) : (
<FlatList
data={filteredItems}
renderItem={renderItem}
keyExtractor={item => item.id}
numColumns={2}
contentContainerStyle={styles.listContainer}
showsVerticalScrollIndicator={false}
columnWrapperStyle={styles.columnWrapper}
initialNumToRender={6}
maxToRenderPerBatch={6}
windowSize={5}
removeClippedSubviews={Platform.OS === 'android'}
/>
{!showTraktContent && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filtersContainer}
style={styles.filtersScrollView}
>
{renderFilter('all', 'All', 'apps')}
{renderFilter('movies', 'Movies', 'movie')}
{renderFilter('series', 'TV Shows', 'live-tv')}
</ScrollView>
)}
{showTraktContent ? renderTraktContent() : renderContent()}
</View>
</View>
</View>
@ -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;

View file

@ -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(() => {