This update modifies the image handling in ContentItem, ContinueWatchingSection, and FeaturedContent components to utilize a more efficient memory caching strategy and adjusted transition durations. Additionally, the HomeScreen component has been optimized for image prefetching, limiting concurrent requests to reduce memory pressure. The ThisWeekSection has been simplified to always refresh episodes when library items change, improving data handling. These changes aim to create a smoother user experience while navigating through content.
570 lines
No EOL
18 KiB
TypeScript
570 lines
No EOL
18 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
FlatList,
|
|
TouchableOpacity,
|
|
Dimensions,
|
|
AppState,
|
|
AppStateStatus
|
|
} from 'react-native';
|
|
import Animated, { FadeIn } from 'react-native-reanimated';
|
|
import { useNavigation } from '@react-navigation/native';
|
|
import { NavigationProp } from '@react-navigation/native';
|
|
import { RootStackParamList } from '../../navigation/AppNavigator';
|
|
import { StreamingContent, catalogService } from '../../services/catalogService';
|
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
import { Image as ExpoImage } from 'expo-image';
|
|
import { useTheme } from '../../contexts/ThemeContext';
|
|
import { storageService } from '../../services/storageService';
|
|
import { logger } from '../../utils/logger';
|
|
|
|
// Define interface for continue watching items
|
|
interface ContinueWatchingItem extends StreamingContent {
|
|
progress: number;
|
|
lastUpdated: number;
|
|
season?: number;
|
|
episode?: number;
|
|
episodeTitle?: string;
|
|
}
|
|
|
|
// Define the ref interface
|
|
interface ContinueWatchingRef {
|
|
refresh: () => Promise<boolean>;
|
|
}
|
|
|
|
// Dynamic poster calculation based on screen width for Continue Watching section
|
|
const calculatePosterLayout = (screenWidth: number) => {
|
|
const MIN_POSTER_WIDTH = 120; // Slightly larger for continue watching items
|
|
const MAX_POSTER_WIDTH = 160; // Maximum poster width for this section
|
|
const HORIZONTAL_PADDING = 40; // Total horizontal padding/margins
|
|
|
|
// Calculate how many posters can fit (fewer items for continue watching)
|
|
const availableWidth = screenWidth - HORIZONTAL_PADDING;
|
|
const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH);
|
|
|
|
// Limit to reasonable number of columns (2-5 for continue watching)
|
|
const numColumns = Math.min(Math.max(maxColumns, 2), 5);
|
|
|
|
// Calculate actual poster width
|
|
const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH);
|
|
|
|
return {
|
|
numColumns,
|
|
posterWidth,
|
|
spacing: 12 // Space between posters
|
|
};
|
|
};
|
|
|
|
const { width } = Dimensions.get('window');
|
|
const posterLayout = calculatePosterLayout(width);
|
|
const POSTER_WIDTH = posterLayout.posterWidth;
|
|
|
|
// Function to validate IMDB ID format
|
|
const isValidImdbId = (id: string): boolean => {
|
|
// IMDB IDs should start with 'tt' followed by 7-10 digits
|
|
const imdbPattern = /^tt\d{7,10}$/;
|
|
return imdbPattern.test(id);
|
|
};
|
|
|
|
// Create a proper imperative handle with React.forwardRef and updated type
|
|
const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, ref) => {
|
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
|
const { currentTheme } = useTheme();
|
|
const [continueWatchingItems, setContinueWatchingItems] = useState<ContinueWatchingItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const appState = useRef(AppState.currentState);
|
|
const refreshTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// Use a state to track if a background refresh is in progress
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
|
|
// Modified loadContinueWatching to be more efficient
|
|
const loadContinueWatching = useCallback(async (isBackgroundRefresh = false) => {
|
|
// Prevent multiple concurrent refreshes
|
|
if (isRefreshing) return;
|
|
|
|
if (!isBackgroundRefresh) {
|
|
setLoading(true);
|
|
}
|
|
setIsRefreshing(true);
|
|
|
|
try {
|
|
const allProgress = await storageService.getAllWatchProgress();
|
|
|
|
if (Object.keys(allProgress).length === 0) {
|
|
setContinueWatchingItems([]);
|
|
return;
|
|
}
|
|
|
|
const progressItems: ContinueWatchingItem[] = [];
|
|
const latestEpisodes: Record<string, ContinueWatchingItem> = {};
|
|
const contentPromises: Promise<void>[] = [];
|
|
|
|
// Process each saved progress
|
|
for (const key in allProgress) {
|
|
// Parse the key to get type and id
|
|
const keyParts = key.split(':');
|
|
const [type, id, ...episodeIdParts] = keyParts;
|
|
const episodeId = episodeIdParts.length > 0 ? episodeIdParts.join(':') : undefined;
|
|
const progress = allProgress[key];
|
|
|
|
// Skip items that are more than 85% complete (effectively finished)
|
|
const progressPercent = (progress.currentTime / progress.duration) * 100;
|
|
|
|
if (progressPercent >= 85) {
|
|
continue;
|
|
}
|
|
|
|
const contentPromise = (async () => {
|
|
try {
|
|
// Validate IMDB ID format before attempting to fetch
|
|
if (!isValidImdbId(id)) {
|
|
return;
|
|
}
|
|
|
|
let content: StreamingContent | null = null;
|
|
|
|
// Get basic content details using catalogService (no enhanced metadata needed for continue watching)
|
|
content = await catalogService.getBasicContentDetails(type, id);
|
|
|
|
if (content) {
|
|
// Extract season and episode info from episodeId if available
|
|
let season: number | undefined;
|
|
let episode: number | undefined;
|
|
let episodeTitle: string | undefined;
|
|
|
|
if (episodeId && type === 'series') {
|
|
// Try different episode ID formats
|
|
let match = episodeId.match(/s(\d+)e(\d+)/i); // Format: s1e1
|
|
if (match) {
|
|
season = parseInt(match[1], 10);
|
|
episode = parseInt(match[2], 10);
|
|
episodeTitle = `Episode ${episode}`;
|
|
} else {
|
|
// Try format: seriesId:season:episode (e.g., tt0108778:4:6)
|
|
const parts = episodeId.split(':');
|
|
if (parts.length >= 3) {
|
|
const seasonPart = parts[parts.length - 2]; // Second to last part
|
|
const episodePart = parts[parts.length - 1]; // Last part
|
|
|
|
const seasonNum = parseInt(seasonPart, 10);
|
|
const episodeNum = parseInt(episodePart, 10);
|
|
|
|
if (!isNaN(seasonNum) && !isNaN(episodeNum)) {
|
|
season = seasonNum;
|
|
episode = episodeNum;
|
|
episodeTitle = `Episode ${episode}`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const continueWatchingItem: ContinueWatchingItem = {
|
|
...content,
|
|
progress: progressPercent,
|
|
lastUpdated: progress.lastUpdated,
|
|
season,
|
|
episode,
|
|
episodeTitle
|
|
};
|
|
|
|
if (type === 'series') {
|
|
// For series, keep only the latest watched episode for each show
|
|
if (!latestEpisodes[id] || latestEpisodes[id].lastUpdated < progress.lastUpdated) {
|
|
latestEpisodes[id] = continueWatchingItem;
|
|
}
|
|
} else {
|
|
// For movies, add to the list directly
|
|
progressItems.push(continueWatchingItem);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to get content details for ${type}:${id}`, error);
|
|
}
|
|
})();
|
|
|
|
contentPromises.push(contentPromise);
|
|
}
|
|
|
|
// Wait for all content to be processed
|
|
await Promise.all(contentPromises);
|
|
|
|
// Add the latest episodes for each series to the items list
|
|
progressItems.push(...Object.values(latestEpisodes));
|
|
|
|
// Sort by last updated time (most recent first)
|
|
progressItems.sort((a, b) => b.lastUpdated - a.lastUpdated);
|
|
|
|
// Show all continue watching items (no limit)
|
|
setContinueWatchingItems(progressItems);
|
|
} catch (error) {
|
|
logger.error('Failed to load continue watching items:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
setIsRefreshing(false);
|
|
}
|
|
}, [isRefreshing]);
|
|
|
|
// Function to handle app state changes
|
|
const handleAppStateChange = useCallback((nextAppState: AppStateStatus) => {
|
|
if (
|
|
appState.current.match(/inactive|background/) &&
|
|
nextAppState === 'active'
|
|
) {
|
|
// App has come to the foreground - trigger a background refresh
|
|
loadContinueWatching(true);
|
|
}
|
|
appState.current = nextAppState;
|
|
}, [loadContinueWatching]);
|
|
|
|
// Set up storage event listener and app state listener
|
|
useEffect(() => {
|
|
// Add app state change listener
|
|
const subscription = AppState.addEventListener('change', handleAppStateChange);
|
|
|
|
// Add custom event listener for watch progress updates
|
|
const watchProgressUpdateHandler = () => {
|
|
// Debounce updates to avoid too frequent refreshes
|
|
if (refreshTimerRef.current) {
|
|
clearTimeout(refreshTimerRef.current);
|
|
}
|
|
refreshTimerRef.current = setTimeout(() => {
|
|
// Trigger a background refresh
|
|
loadContinueWatching(true);
|
|
}, 500); // Increased debounce time slightly
|
|
};
|
|
|
|
// Try to set up a custom event listener or use a timer as fallback
|
|
if (storageService.subscribeToWatchProgressUpdates) {
|
|
const unsubscribe = storageService.subscribeToWatchProgressUpdates(watchProgressUpdateHandler);
|
|
return () => {
|
|
subscription.remove();
|
|
unsubscribe();
|
|
if (refreshTimerRef.current) {
|
|
clearTimeout(refreshTimerRef.current);
|
|
}
|
|
};
|
|
} else {
|
|
// Fallback: poll for updates every 30 seconds
|
|
const intervalId = setInterval(() => loadContinueWatching(true), 30000);
|
|
return () => {
|
|
subscription.remove();
|
|
clearInterval(intervalId);
|
|
if (refreshTimerRef.current) {
|
|
clearTimeout(refreshTimerRef.current);
|
|
}
|
|
};
|
|
}
|
|
}, [loadContinueWatching, handleAppStateChange]);
|
|
|
|
// Initial load
|
|
useEffect(() => {
|
|
loadContinueWatching();
|
|
}, [loadContinueWatching]);
|
|
|
|
// Expose the refresh function via the ref
|
|
React.useImperativeHandle(ref, () => ({
|
|
refresh: async () => {
|
|
// Allow manual refresh to show loading indicator
|
|
await loadContinueWatching(false);
|
|
return true;
|
|
}
|
|
}));
|
|
|
|
const handleContentPress = useCallback((id: string, type: string) => {
|
|
navigation.navigate('Metadata', { id, type });
|
|
}, [navigation]);
|
|
|
|
// If no continue watching items, don't render anything
|
|
if (continueWatchingItems.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Animated.View entering={FadeIn.duration(300).delay(150)} style={styles.container}>
|
|
<View style={styles.header}>
|
|
<View style={styles.titleContainer}>
|
|
<Text style={[styles.title, { color: currentTheme.colors.text }]}>Continue Watching</Text>
|
|
<View style={[styles.titleUnderline, { backgroundColor: currentTheme.colors.primary }]} />
|
|
</View>
|
|
</View>
|
|
|
|
<FlatList
|
|
data={continueWatchingItems}
|
|
renderItem={({ item }) => (
|
|
<TouchableOpacity
|
|
style={[styles.wideContentItem, {
|
|
backgroundColor: currentTheme.colors.elevation1,
|
|
borderColor: currentTheme.colors.border,
|
|
shadowColor: currentTheme.colors.black
|
|
}]}
|
|
activeOpacity={0.8}
|
|
onPress={() => handleContentPress(item.id, item.type)}
|
|
>
|
|
{/* Poster Image */}
|
|
<View style={styles.posterContainer}>
|
|
<ExpoImage
|
|
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
|
|
style={styles.continueWatchingPoster}
|
|
contentFit="cover"
|
|
cachePolicy="memory"
|
|
transition={200}
|
|
placeholder={{ uri: 'https://via.placeholder.com/300x450' }}
|
|
placeholderContentFit="cover"
|
|
recyclingKey={item.id}
|
|
/>
|
|
</View>
|
|
|
|
{/* Content Details */}
|
|
<View style={styles.contentDetails}>
|
|
<View style={styles.titleRow}>
|
|
<Text
|
|
style={[styles.contentTitle, { color: currentTheme.colors.highEmphasis }]}
|
|
numberOfLines={1}
|
|
>
|
|
{item.name}
|
|
</Text>
|
|
<View style={[styles.progressBadge, { backgroundColor: currentTheme.colors.primary }]}>
|
|
<Text style={styles.progressText}>{Math.round(item.progress)}%</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Episode Info or Year */}
|
|
{(() => {
|
|
if (item.type === 'series' && item.season && item.episode) {
|
|
return (
|
|
<View style={styles.episodeRow}>
|
|
<Text style={[styles.episodeText, { color: currentTheme.colors.mediumEmphasis }]}>
|
|
Season {item.season}
|
|
</Text>
|
|
{item.episodeTitle && (
|
|
<Text
|
|
style={[styles.episodeTitle, { color: currentTheme.colors.mediumEmphasis }]}
|
|
numberOfLines={1}
|
|
>
|
|
{item.episodeTitle}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
);
|
|
} else {
|
|
return (
|
|
<Text style={[styles.yearText, { color: currentTheme.colors.mediumEmphasis }]}>
|
|
{item.year} • {item.type === 'movie' ? 'Movie' : 'Series'}
|
|
</Text>
|
|
);
|
|
}
|
|
})()}
|
|
|
|
{/* Progress Bar */}
|
|
<View style={styles.wideProgressContainer}>
|
|
<View style={styles.wideProgressTrack}>
|
|
<View
|
|
style={[
|
|
styles.wideProgressBar,
|
|
{
|
|
width: `${item.progress}%`,
|
|
backgroundColor: currentTheme.colors.primary
|
|
}
|
|
]}
|
|
/>
|
|
</View>
|
|
<Text style={[styles.progressLabel, { color: currentTheme.colors.textMuted }]}>
|
|
{Math.round(item.progress)}% watched
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</TouchableOpacity>
|
|
)}
|
|
keyExtractor={(item) => `continue-${item.id}-${item.type}`}
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={styles.wideList}
|
|
snapToInterval={280 + 16} // Card width + margin
|
|
decelerationRate="fast"
|
|
snapToAlignment="start"
|
|
ItemSeparatorComponent={() => <View style={{ width: 16 }} />}
|
|
/>
|
|
</Animated.View>
|
|
);
|
|
});
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
marginBottom: 28,
|
|
paddingTop: 0,
|
|
marginTop: 12,
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 16,
|
|
marginBottom: 16,
|
|
},
|
|
titleContainer: {
|
|
position: 'relative',
|
|
},
|
|
title: {
|
|
fontSize: 24,
|
|
fontWeight: '800',
|
|
letterSpacing: 0.5,
|
|
marginBottom: 4,
|
|
},
|
|
titleUnderline: {
|
|
position: 'absolute',
|
|
bottom: -2,
|
|
left: 0,
|
|
width: 40,
|
|
height: 3,
|
|
borderRadius: 2,
|
|
opacity: 0.8,
|
|
},
|
|
wideList: {
|
|
paddingHorizontal: 16,
|
|
paddingBottom: 8,
|
|
paddingTop: 4,
|
|
},
|
|
wideContentItem: {
|
|
width: 280,
|
|
height: 120,
|
|
flexDirection: 'row',
|
|
borderRadius: 12,
|
|
overflow: 'hidden',
|
|
elevation: 6,
|
|
shadowOffset: { width: 0, height: 3 },
|
|
shadowOpacity: 0.2,
|
|
shadowRadius: 6,
|
|
borderWidth: 1,
|
|
},
|
|
posterContainer: {
|
|
width: 80,
|
|
height: '100%',
|
|
},
|
|
continueWatchingPoster: {
|
|
width: '100%',
|
|
height: '100%',
|
|
borderTopLeftRadius: 12,
|
|
borderBottomLeftRadius: 12,
|
|
},
|
|
contentDetails: {
|
|
flex: 1,
|
|
padding: 12,
|
|
justifyContent: 'space-between',
|
|
},
|
|
titleRow: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'flex-start',
|
|
marginBottom: 4,
|
|
},
|
|
contentTitle: {
|
|
fontSize: 16,
|
|
fontWeight: '700',
|
|
flex: 1,
|
|
marginRight: 8,
|
|
},
|
|
progressBadge: {
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 3,
|
|
borderRadius: 12,
|
|
minWidth: 44,
|
|
alignItems: 'center',
|
|
},
|
|
progressText: {
|
|
fontSize: 12,
|
|
fontWeight: '700',
|
|
color: '#FFFFFF',
|
|
},
|
|
episodeRow: {
|
|
marginBottom: 8,
|
|
},
|
|
episodeText: {
|
|
fontSize: 13,
|
|
fontWeight: '600',
|
|
marginBottom: 2,
|
|
},
|
|
episodeTitle: {
|
|
fontSize: 12,
|
|
},
|
|
yearText: {
|
|
fontSize: 13,
|
|
fontWeight: '500',
|
|
marginBottom: 8,
|
|
},
|
|
wideProgressContainer: {
|
|
marginTop: 'auto',
|
|
},
|
|
wideProgressTrack: {
|
|
height: 4,
|
|
backgroundColor: 'rgba(255,255,255,0.1)',
|
|
borderRadius: 2,
|
|
marginBottom: 4,
|
|
},
|
|
wideProgressBar: {
|
|
height: '100%',
|
|
borderRadius: 2,
|
|
},
|
|
progressLabel: {
|
|
fontSize: 11,
|
|
fontWeight: '500',
|
|
},
|
|
// Keep old styles for backward compatibility
|
|
list: {
|
|
paddingHorizontal: 16,
|
|
paddingBottom: 8,
|
|
paddingTop: 4,
|
|
},
|
|
contentItem: {
|
|
width: POSTER_WIDTH,
|
|
aspectRatio: 2/3,
|
|
margin: 0,
|
|
borderRadius: 8,
|
|
overflow: 'hidden',
|
|
position: 'relative',
|
|
elevation: 8,
|
|
shadowOffset: { width: 0, height: 4 },
|
|
shadowOpacity: 0.3,
|
|
shadowRadius: 8,
|
|
borderWidth: 1,
|
|
},
|
|
contentItemContainer: {
|
|
width: '100%',
|
|
height: '100%',
|
|
borderRadius: 8,
|
|
overflow: 'hidden',
|
|
position: 'relative',
|
|
},
|
|
poster: {
|
|
width: '100%',
|
|
height: '100%',
|
|
borderRadius: 8,
|
|
},
|
|
episodeInfoContainer: {
|
|
position: 'absolute',
|
|
bottom: 3,
|
|
left: 0,
|
|
right: 0,
|
|
padding: 4,
|
|
paddingHorizontal: 8,
|
|
},
|
|
episodeInfo: {
|
|
fontSize: 12,
|
|
fontWeight: 'bold',
|
|
},
|
|
progressBarContainer: {
|
|
position: 'absolute',
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: 3,
|
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
},
|
|
progressBar: {
|
|
height: '100%',
|
|
},
|
|
});
|
|
|
|
export default React.memo(ContinueWatchingSection);
|