import React, { useState, useEffect, useCallback, useRef } from 'react'; import { View, Text, StyleSheet, FlatList, TouchableOpacity, Dimensions, AppState, AppStateStatus, Alert, ActivityIndicator } from 'react-native'; import Animated, { FadeIn, FadeOut } 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'; import * as Haptics from 'expo-haptics'; import { TraktService } from '../../services/traktService'; // 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; } // 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((props, ref) => { const navigation = useNavigation>(); const { currentTheme } = useTheme(); const [continueWatchingItems, setContinueWatchingItems] = useState([]); const [loading, setLoading] = useState(true); const appState = useRef(AppState.currentState); const refreshTimerRef = useRef(null); const [deletingItemId, setDeletingItemId] = useState(null); const longPressTimeoutRef = useRef(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 = {}; const contentPromises: Promise[] = []; // 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]; // For series, skip episodes that are essentially finished (≥85%) // For movies we still include them so users can "Watch Again" const progressPercent = (progress.currentTime / progress.duration) * 100; // Skip fully watched movies if (type === 'movie' && progressPercent >= 85) { continue; } if (type === 'series' && progressPercent >= 85) { // Determine next episode ID by incrementing episode number let nextSeason: number | undefined; let nextEpisode: number | undefined; let nextEpisodeId: string | undefined; if (episodeId) { // Pattern 1: s1e1 const match = episodeId.match(/s(\d+)e(\d+)/i); if (match) { const currentSeason = parseInt(match[1], 10); const currentEpisode = parseInt(match[2], 10); nextSeason = currentSeason; nextEpisode = currentEpisode + 1; nextEpisodeId = `s${nextSeason}e${nextEpisode}`; } else { // Pattern 2: id:season:episode const parts = episodeId.split(':'); if (parts.length >= 2) { const seasonNum = parseInt(parts[parts.length - 2], 10); const episodeNum = parseInt(parts[parts.length - 1], 10); if (!isNaN(seasonNum) && !isNaN(episodeNum)) { nextSeason = seasonNum; nextEpisode = episodeNum + 1; nextEpisodeId = `${id}:${nextSeason}:${nextEpisode}`; } } } } // Push placeholder for next episode with 0% progress if (nextEpisodeId !== undefined) { const basicContent = await catalogService.getBasicContentDetails(type, id); const nextEpisodeItem = { ...basicContent, id, type, progress: 0, lastUpdated: progress.lastUpdated, season: nextSeason, episode: nextEpisode, episodeTitle: `Episode ${nextEpisode}`, } as ContinueWatchingItem; // Store in latestEpisodes to ensure single entry per show const existingLatest = latestEpisodes[id]; if (!existingLatest || existingLatest.lastUpdated < nextEpisodeItem.lastUpdated) { latestEpisodes[id] = nextEpisodeItem; } } // Skip adding the finished episode itself 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); // -------------------- TRAKT HISTORY INTEGRATION -------------------- try { const traktService = TraktService.getInstance(); const isAuthed = await traktService.isAuthenticated(); if (isAuthed) { const historyItems = await traktService.getWatchedEpisodesHistory(1, 200); const latestWatchedByShow: Record = {}; for (const item of historyItems) { if (item.type !== 'episode') continue; const showImdb = item.show?.ids?.imdb ? `tt${item.show.ids.imdb.replace(/^tt/, '')}` : null; if (!showImdb) continue; const season = item.episode?.season; const epNum = item.episode?.number; if (season === undefined || epNum === undefined) continue; const watchedAt = new Date(item.watched_at).getTime(); const existing = latestWatchedByShow[showImdb]; if (!existing || existing.watchedAt < watchedAt) { latestWatchedByShow[showImdb] = { season, episode: epNum, watchedAt }; } } // Create placeholders (or update) for each show based on Trakt history for (const [showId, info] of Object.entries(latestWatchedByShow)) { const nextEpisode = info.episode + 1; const nextEpisodeId = `${showId}:${info.season}:${nextEpisode}`; try { const basicContent = await catalogService.getBasicContentDetails('series', showId); if (!basicContent) continue; const placeholder: ContinueWatchingItem = { ...basicContent, id: showId, type: 'series', progress: 0, lastUpdated: info.watchedAt, season: info.season, episode: nextEpisode, episodeTitle: `Episode ${nextEpisode}`, } as ContinueWatchingItem; const existing = latestEpisodes[showId]; if (!existing || existing.lastUpdated < info.watchedAt) { latestEpisodes[showId] = placeholder; } // Persist "watched" progress for the episode that Trakt reported const watchedEpisodeId = `${showId}:${info.season}:${info.episode}`; const existingProgress = allProgress[`series:${showId}:${watchedEpisodeId}`]; const existingPercent = existingProgress ? (existingProgress.currentTime / existingProgress.duration) * 100 : 0; if (!existingProgress || existingPercent < 85) { await storageService.setWatchProgress( showId, 'series', { currentTime: 1, duration: 1, lastUpdated: info.watchedAt, traktSynced: true, traktProgress: 100, } as any, `${info.season}:${info.episode}` ); } } catch (err) { logger.error('Failed to build placeholder from history:', err); } } } } catch (err) { logger.error('Error merging Trakt history:', err); } // 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); } if (longPressTimeoutRef.current) { clearTimeout(longPressTimeoutRef.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); } if (longPressTimeoutRef.current) { clearTimeout(longPressTimeoutRef.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]); // Handle long press to delete const handleLongPress = useCallback((item: ContinueWatchingItem) => { try { // Trigger haptic feedback Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); } catch (error) { // Ignore haptic errors } // Show confirmation alert Alert.alert( "Remove from Continue Watching", `Remove "${item.name}" from your continue watching list?`, [ { text: "Cancel", style: "cancel" }, { text: "Remove", style: "destructive", onPress: async () => { setDeletingItemId(item.id); try { // Trigger haptic feedback for confirmation Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); // Remove the watch progress await storageService.removeWatchProgress( item.id, item.type, item.type === 'series' && item.season && item.episode ? `${item.season}:${item.episode}` : undefined ); // Also remove from Trakt playback queue if authenticated const traktService = TraktService.getInstance(); const isAuthed = await traktService.isAuthenticated(); if (isAuthed) { await traktService.deletePlaybackForContent( item.id, item.type as 'movie' | 'series', item.season, item.episode ); } // Update the list by filtering out the deleted item setContinueWatchingItems(prev => prev.filter(i => i.id !== item.id || (i.type === 'series' && item.type === 'series' && (i.season !== item.season || i.episode !== item.episode)) ) ); } catch (error) { logger.error('Failed to remove watch progress:', error); } finally { setDeletingItemId(null); } } } ] ); }, []); // If no continue watching items, don't render anything if (continueWatchingItems.length === 0) { return null; } return ( Continue Watching ( handleContentPress(item.id, item.type)} onLongPress={() => handleLongPress(item)} delayLongPress={800} > {/* Poster Image */} {/* Delete Indicator Overlay */} {deletingItemId === item.id && ( )} {/* Content Details */} {(() => { const isUpNext = item.progress === 0; return ( {item.name} {isUpNext ? 'Up Next' : `${Math.round(item.progress)}%`} ); })()} {/* Episode Info or Year */} {(() => { if (item.type === 'series' && item.season && item.episode) { return ( Season {item.season} {item.episodeTitle && ( {item.episodeTitle} )} ); } else { return ( {item.year} • {item.type === 'movie' ? 'Movie' : 'Series'} ); } })()} {/* Progress Bar */} {item.progress > 0 && ( {Math.round(item.progress)}% watched )} )} keyExtractor={(item) => `continue-${item.id}-${item.type}`} horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.wideList} snapToInterval={280 + 16} // Card width + margin decelerationRate="fast" snapToAlignment="start" ItemSeparatorComponent={() => } /> ); }); 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%', position: 'relative', }, continueWatchingPoster: { width: '100%', height: '100%', borderTopLeftRadius: 12, borderBottomLeftRadius: 12, }, deletingOverlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'center', alignItems: 'center', 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);