NuvioStreaming_backup_24-10-25/src/components/home/ContinueWatchingSection.tsx
2025-10-21 16:09:35 +05:30

1253 lines
No EOL
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Dimensions,
AppState,
AppStateStatus,
ActivityIndicator,
Platform
} from 'react-native';
import { FlashList } from '@shopify/flash-list';
import Animated, { FadeIn, Layout } from 'react-native-reanimated';
import { useNavigation, useFocusEffect } 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 FastImage from '@d11/react-native-fast-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';
import { stremioService } from '../../services/stremioService';
import { streamCacheService } from '../../services/streamCacheService';
import { useSettings } from '../../hooks/useSettings';
import CustomAlert from '../../components/CustomAlert';
// 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>;
}
// Enhanced responsive breakpoints for Continue Watching section
const BREAKPOINTS = {
phone: 0,
tablet: 768,
largeTablet: 1024,
tv: 1440,
};
// 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;
// Allow any known id formats (imdb 'tt...', kitsu 'kitsu:...', tmdb 'tmdb:...', or others)
const isSupportedId = (id: string): boolean => {
return typeof id === 'string' && id.length > 0;
};
// Function to check if an episode has been released
const isEpisodeReleased = (video: any): boolean => {
if (!video.released) return false;
try {
const releaseDate = new Date(video.released);
const now = new Date();
return releaseDate <= now;
} catch (error) {
// If we can't parse the date, assume it's not released
return false;
}
};
// 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 { settings } = useSettings();
const [continueWatchingItems, setContinueWatchingItems] = useState<ContinueWatchingItem[]>([]);
const [loading, setLoading] = useState(true);
const appState = useRef(AppState.currentState);
const refreshTimerRef = useRef<NodeJS.Timeout | null>(null);
const [deletingItemId, setDeletingItemId] = useState<string | null>(null);
const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Enhanced responsive sizing for tablets and TV screens
const deviceWidth = Dimensions.get('window').width;
const deviceHeight = Dimensions.get('window').height;
// Determine device type based on width
const getDeviceType = useCallback(() => {
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
}, [deviceWidth]);
const deviceType = getDeviceType();
const isTablet = deviceType === 'tablet';
const isLargeTablet = deviceType === 'largeTablet';
const isTV = deviceType === 'tv';
const isLargeScreen = isTablet || isLargeTablet || isTV;
// Enhanced responsive sizing for continue watching items
const computedItemWidth = useMemo(() => {
switch (deviceType) {
case 'tv':
return 400; // Larger items for TV
case 'largeTablet':
return 350; // Medium-large items for large tablets
case 'tablet':
return 320; // Medium items for tablets
default:
return 280; // Original phone size
}
}, [deviceType]);
const computedItemHeight = useMemo(() => {
switch (deviceType) {
case 'tv':
return 160; // Taller items for TV
case 'largeTablet':
return 140; // Medium-tall items for large tablets
case 'tablet':
return 130; // Medium items for tablets
default:
return 120; // Original phone height
}
}, [deviceType]);
// Enhanced spacing and padding
const horizontalPadding = useMemo(() => {
switch (deviceType) {
case 'tv':
return 32;
case 'largeTablet':
return 28;
case 'tablet':
return 24;
default:
return 16; // phone
}
}, [deviceType]);
const itemSpacing = useMemo(() => {
switch (deviceType) {
case 'tv':
return 20;
case 'largeTablet':
return 18;
case 'tablet':
return 16;
default:
return 16; // phone
}
}, [deviceType]);
// Alert state for CustomAlert
const [alertVisible, setAlertVisible] = useState(false);
const [alertTitle, setAlertTitle] = useState('');
const [alertMessage, setAlertMessage] = useState('');
const [alertActions, setAlertActions] = useState<any[]>([]);
// Use a ref to track if a background refresh is in progress to avoid state updates
const isRefreshingRef = useRef(false);
// Track recently removed items to prevent immediate re-addition
const recentlyRemovedRef = useRef<Set<string>>(new Set());
const REMOVAL_IGNORE_DURATION = 10000; // 10 seconds
// Track last Trakt sync to prevent excessive API calls
const lastTraktSyncRef = useRef<number>(0);
const TRAKT_SYNC_COOLDOWN = 5 * 60 * 1000; // 5 minutes between Trakt syncs
// Cache for metadata to avoid redundant API calls
const metadataCache = useRef<Record<string, { metadata: any; basicContent: StreamingContent | null; timestamp: number }>>({});
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
// Helper function to get cached or fetch metadata
const getCachedMetadata = useCallback(async (type: string, id: string) => {
const cacheKey = `${type}:${id}`;
const cached = metadataCache.current[cacheKey];
const now = Date.now();
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
return cached;
}
try {
const shouldFetchMeta = await stremioService.isValidContentId(type, id);
const [metadata, basicContent] = await Promise.all([
shouldFetchMeta ? stremioService.getMetaDetails(type, id) : Promise.resolve(null),
catalogService.getBasicContentDetails(type, id)
]);
if (basicContent) {
const result = { metadata, basicContent, timestamp: now };
metadataCache.current[cacheKey] = result;
return result;
}
return null;
} catch (error: any) {
// Skip logging 404 errors to reduce noise
return null;
}
}, []);
// Modified loadContinueWatching to render incrementally
const loadContinueWatching = useCallback(async (isBackgroundRefresh = false) => {
if (isRefreshingRef.current) {
return;
}
if (!isBackgroundRefresh) {
setLoading(true);
}
isRefreshingRef.current = true;
// Helper to merge a batch of items into state (dedupe by type:id, keep newest)
const mergeBatchIntoState = async (batch: ContinueWatchingItem[]) => {
if (!batch || batch.length === 0) return;
setContinueWatchingItems((prev) => {
const map = new Map<string, ContinueWatchingItem>();
for (const it of prev) {
map.set(`${it.type}:${it.id}`, it);
}
const merged = Array.from(map.values());
merged.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0));
return merged;
});
// Process batch items asynchronously to check removal status
for (const it of batch) {
const key = `${it.type}:${it.id}`;
// Skip recently removed items to prevent immediate re-addition
if (recentlyRemovedRef.current.has(key)) {
continue;
}
// Skip items that have been persistently marked as removed
const isRemoved = await storageService.isContinueWatchingRemoved(it.id, it.type);
if (isRemoved) {
continue;
}
// Add the item to state
setContinueWatchingItems((prev) => {
const map = new Map<string, ContinueWatchingItem>();
for (const existing of prev) {
map.set(`${existing.type}:${existing.id}`, existing);
}
const existing = map.get(key);
if (!existing || (it.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) {
map.set(key, it);
const merged = Array.from(map.values());
merged.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0));
return merged;
}
return prev;
});
}
};
try {
const allProgress = await storageService.getAllWatchProgress();
if (Object.keys(allProgress).length === 0) {
setContinueWatchingItems([]);
return;
}
// Group progress items by content ID
const contentGroups: Record<string, { type: string; id: string; episodes: Array<{ key: string; episodeId?: string; progress: any; progressPercent: number }> }> = {};
for (const key in allProgress) {
const keyParts = key.split(':');
const [type, id, ...episodeIdParts] = keyParts;
const episodeId = episodeIdParts.length > 0 ? episodeIdParts.join(':') : undefined;
const progress = allProgress[key];
const progressPercent = (progress.currentTime / progress.duration) * 100;
// Skip fully watched movies
if (type === 'movie' && progressPercent >= 85) continue;
// Skip movies with no actual progress (ensure > 0%)
if (type === 'movie' && (!isFinite(progressPercent) || progressPercent <= 0)) continue;
const contentKey = `${type}:${id}`;
if (!contentGroups[contentKey]) contentGroups[contentKey] = { type, id, episodes: [] };
contentGroups[contentKey].episodes.push({ key, episodeId, progress, progressPercent });
}
// Fetch Trakt watched movies once and reuse
const traktMoviesSetPromise = (async () => {
try {
const traktService = TraktService.getInstance();
const isAuthed = await traktService.isAuthenticated();
if (!isAuthed) return new Set<string>();
if (typeof (traktService as any).getWatchedMovies === 'function') {
const watched = await (traktService as any).getWatchedMovies();
if (Array.isArray(watched)) {
const ids = watched
.map((w: any) => w?.movie?.ids?.imdb)
.filter(Boolean)
.map((imdb: string) => (imdb.startsWith('tt') ? imdb : `tt${imdb}`));
return new Set<string>(ids);
}
}
return new Set<string>();
} catch {
return new Set<string>();
}
})();
// Process each content group concurrently, merging results as they arrive
const groupPromises = Object.values(contentGroups).map(async (group) => {
try {
if (!isSupportedId(group.id)) return;
// Skip movies that are already watched on Trakt
if (group.type === 'movie') {
const watchedSet = await traktMoviesSetPromise;
if (watchedSet.has(group.id)) {
// Optional: sync local store to watched to prevent reappearance
try {
await storageService.setWatchProgress(group.id, 'movie', {
currentTime: 1,
duration: 1,
lastUpdated: Date.now(),
traktSynced: true,
traktProgress: 100,
} as any);
} catch (_e) {}
return;
}
}
const cachedData = await getCachedMetadata(group.type, group.id);
if (!cachedData?.basicContent) return;
const { metadata, basicContent } = cachedData;
const batch: ContinueWatchingItem[] = [];
for (const episode of group.episodes) {
const { episodeId, progress, progressPercent } = episode;
if (group.type === 'series' && progressPercent >= 85) {
let nextSeason: number | undefined;
let nextEpisode: number | undefined;
if (episodeId) {
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;
} else {
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;
}
}
}
}
if (nextSeason !== undefined && nextEpisode !== undefined && metadata?.videos && Array.isArray(metadata.videos)) {
const nextEpisodeVideo = metadata.videos.find((video: any) =>
video.season === nextSeason && video.episode === nextEpisode
);
if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) {
batch.push({
...basicContent,
id: group.id,
type: group.type,
progress: 0,
lastUpdated: progress.lastUpdated,
season: nextSeason,
episode: nextEpisode,
episodeTitle: `Episode ${nextEpisode}`,
} as ContinueWatchingItem);
}
}
continue;
}
let season: number | undefined;
let episodeNumber: number | undefined;
let episodeTitle: string | undefined;
if (episodeId && group.type === 'series') {
let match = episodeId.match(/s(\d+)e(\d+)/i);
if (match) {
season = parseInt(match[1], 10);
episodeNumber = parseInt(match[2], 10);
episodeTitle = `Episode ${episodeNumber}`;
} else {
const parts = episodeId.split(':');
if (parts.length >= 3) {
const seasonPart = parts[parts.length - 2];
const episodePart = parts[parts.length - 1];
const seasonNum = parseInt(seasonPart, 10);
const episodeNum = parseInt(episodePart, 10);
if (!isNaN(seasonNum) && !isNaN(episodeNum)) {
season = seasonNum;
episodeNumber = episodeNum;
episodeTitle = `Episode ${episodeNumber}`;
}
}
}
}
batch.push({
...basicContent,
progress: progressPercent,
lastUpdated: progress.lastUpdated,
season,
episode: episodeNumber,
episodeTitle,
} as ContinueWatchingItem);
}
if (batch.length > 0) await mergeBatchIntoState(batch);
} catch (error) {
// Continue processing other groups even if one fails
}
});
// TRÅKT: fetch history and merge incrementally as well
const traktMergePromise = (async () => {
try {
const traktService = TraktService.getInstance();
const isAuthed = await traktService.isAuthenticated();
if (!isAuthed) return;
// Check Trakt sync cooldown to prevent excessive API calls
const now = Date.now();
if (now - lastTraktSyncRef.current < TRAKT_SYNC_COOLDOWN) {
logger.log(`[TraktSync] Skipping Trakt sync - cooldown active (${Math.round((TRAKT_SYNC_COOLDOWN - (now - lastTraktSyncRef.current)) / 1000)}s remaining)`);
return;
}
lastTraktSyncRef.current = now;
const historyItems = await traktService.getWatchedEpisodesHistory(1, 200);
const latestWatchedByShow: Record<string, { season: number; episode: number; watchedAt: number }> = {};
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 };
}
}
// Collect all valid Trakt items first, then merge as a batch
const traktBatch: ContinueWatchingItem[] = [];
for (const [showId, info] of Object.entries(latestWatchedByShow)) {
try {
// Check if this show was recently removed by the user
const showKey = `series:${showId}`;
if (recentlyRemovedRef.current.has(showKey)) {
logger.log(`🚫 [TraktSync] Skipping recently removed show: ${showKey}`);
continue;
}
const nextEpisode = info.episode + 1;
const cachedData = await getCachedMetadata('series', showId);
if (!cachedData?.basicContent) continue;
const { metadata, basicContent } = cachedData;
let nextEpisodeVideo = null;
if (metadata?.videos && Array.isArray(metadata.videos)) {
nextEpisodeVideo = metadata.videos.find((video: any) =>
video.season === info.season && video.episode === nextEpisode
);
}
if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) {
logger.log(` [TraktSync] Adding next episode for ${showId}: S${info.season}E${nextEpisode}`);
traktBatch.push({
...basicContent,
id: showId,
type: 'series',
progress: 0,
lastUpdated: info.watchedAt,
season: info.season,
episode: nextEpisode,
episodeTitle: `Episode ${nextEpisode}`,
} as ContinueWatchingItem);
}
// Persist "watched" progress for the episode that Trakt reported (only if not recently removed)
if (!recentlyRemovedRef.current.has(showKey)) {
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) {
logger.log(`💾 [TraktSync] Adding local progress for ${showId}: S${info.season}E${info.episode}`);
await storageService.setWatchProgress(
showId,
'series',
{
currentTime: 1,
duration: 1,
lastUpdated: info.watchedAt,
traktSynced: true,
traktProgress: 100,
} as any,
`${info.season}:${info.episode}`
);
}
} else {
logger.log(`🚫 [TraktSync] Skipping local progress for recently removed show: ${showKey}`);
}
} catch (err) {
// Continue with other shows even if one fails
}
}
// Merge all Trakt items as a single batch to ensure proper sorting
if (traktBatch.length > 0) {
await mergeBatchIntoState(traktBatch);
}
} catch (err) {
// Continue even if Trakt history merge fails
}
})();
// Wait for all groups and trakt merge to settle, then finalize loading state
await Promise.allSettled([...groupPromises, traktMergePromise]);
} catch (error) {
// Continue even if loading fails
} finally {
setLoading(false);
isRefreshingRef.current = false;
}
}, [getCachedMetadata]);
// Clear cache when component unmounts or when needed
useEffect(() => {
return () => {
metadataCache.current = {};
};
}, []);
// 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 - force Trakt sync by resetting cooldown
lastTraktSyncRef.current = 0; // Reset cooldown to allow immediate Trakt sync
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(() => {
// Only trigger background refresh for local progress updates, not Trakt sync
// This prevents the feedback loop where Trakt sync triggers more progress updates
loadContinueWatching(true);
}, 2000); // Increased debounce to reduce frequency
};
// 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 {
// Reduced polling frequency from 30s to 5 minutes to reduce heating and battery drain
const intervalId = setInterval(() => loadContinueWatching(true), 300000);
return () => {
subscription.remove();
clearInterval(intervalId);
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
}
if (longPressTimeoutRef.current) {
clearTimeout(longPressTimeoutRef.current);
}
};
}
}, [loadContinueWatching, handleAppStateChange]);
// Initial load
useEffect(() => {
loadContinueWatching();
}, [loadContinueWatching]);
// Refresh on screen focus (lightweight, no polling)
useFocusEffect(
useCallback(() => {
loadContinueWatching(true);
return () => {};
}, [loadContinueWatching])
);
// Expose the refresh function via the ref
React.useImperativeHandle(ref, () => ({
refresh: async () => {
// Manual refresh bypasses Trakt cooldown to get fresh data
lastTraktSyncRef.current = 0; // Reset cooldown for manual refresh
await loadContinueWatching(false);
return true;
}
}));
const handleContentPress = useCallback(async (item: ContinueWatchingItem) => {
try {
logger.log(`🎬 [ContinueWatching] User clicked on: ${item.name} (${item.type}:${item.id})`);
// Check if cached streams are enabled in settings
if (!settings.useCachedStreams) {
logger.log(`📺 [ContinueWatching] Cached streams disabled, navigating to ${settings.openMetadataScreenWhenCacheDisabled ? 'MetadataScreen' : 'StreamsScreen'} for ${item.name}`);
// Navigate based on the second setting
if (settings.openMetadataScreenWhenCacheDisabled) {
// Navigate to MetadataScreen
if (item.type === 'series' && item.season && item.episode) {
const episodeId = `${item.id}:${item.season}:${item.episode}`;
navigation.navigate('Metadata', {
id: item.id,
type: item.type,
episodeId: episodeId
});
} else {
navigation.navigate('Metadata', {
id: item.id,
type: item.type
});
}
} else {
// Navigate to StreamsScreen
if (item.type === 'series' && item.season && item.episode) {
const episodeId = `${item.id}:${item.season}:${item.episode}`;
navigation.navigate('Streams', {
id: item.id,
type: item.type,
episodeId: episodeId
});
} else {
navigation.navigate('Streams', {
id: item.id,
type: item.type
});
}
}
return;
}
// Check if we have a cached stream for this content
const episodeId = item.type === 'series' && item.season && item.episode
? `${item.id}:${item.season}:${item.episode}`
: undefined;
logger.log(`🔍 [ContinueWatching] Looking for cached stream with episodeId: ${episodeId || 'none'}`);
const cachedStream = await streamCacheService.getCachedStream(item.id, item.type, episodeId);
if (cachedStream) {
// We have a valid cached stream, navigate directly to player
logger.log(`🚀 [ContinueWatching] Using cached stream for ${item.name}`);
// Determine the player route based on platform
const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid';
// Navigate directly to player with cached stream data
navigation.navigate(playerRoute as any, {
uri: cachedStream.stream.url,
title: cachedStream.metadata?.name || item.name,
episodeTitle: cachedStream.episodeTitle || (item.type === 'series' ? `Episode ${item.episode}` : undefined),
season: cachedStream.season || item.season,
episode: cachedStream.episode || item.episode,
quality: (cachedStream.stream.title?.match(/(\d+)p/) || [])[1] || undefined,
year: cachedStream.metadata?.year || item.year,
streamProvider: cachedStream.stream.addonId || cachedStream.stream.addonName || cachedStream.stream.name,
streamName: cachedStream.stream.name || cachedStream.stream.title || 'Unnamed Stream',
headers: cachedStream.stream.headers || undefined,
forceVlc: false,
id: item.id,
type: item.type,
episodeId: episodeId,
imdbId: cachedStream.metadata?.imdbId || item.imdb_id,
backdrop: cachedStream.metadata?.backdrop || item.banner,
videoType: undefined, // Let player auto-detect
} as any);
return;
}
// No cached stream or cache failed, navigate to StreamsScreen
logger.log(`📺 [ContinueWatching] No cached stream, navigating to StreamsScreen for ${item.name}`);
if (item.type === 'series' && item.season && item.episode) {
// For series, navigate to the specific episode
navigation.navigate('Streams', {
id: item.id,
type: item.type,
episodeId: episodeId
});
} else {
// For movies or series without specific episode, navigate to main content
navigation.navigate('Streams', {
id: item.id,
type: item.type
});
}
} catch (error) {
logger.warn('[ContinueWatching] Error handling content press:', error);
// Fallback to StreamsScreen on any error
if (item.type === 'series' && item.season && item.episode) {
const episodeId = `${item.id}:${item.season}:${item.episode}`;
navigation.navigate('Streams', {
id: item.id,
type: item.type,
episodeId: episodeId
});
} else {
navigation.navigate('Streams', {
id: item.id,
type: item.type
});
}
}
}, [navigation, settings.useCachedStreams, settings.openMetadataScreenWhenCacheDisabled]);
// Handle long press to delete (moved before renderContinueWatchingItem)
const handleLongPress = useCallback((item: ContinueWatchingItem) => {
try {
// Trigger haptic feedback
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
} catch (error) {
// Ignore haptic errors
}
setAlertTitle('Remove from Continue Watching');
setAlertMessage(`Remove "${item.name}" from your continue watching list?`);
setAlertActions([
{
label: 'Cancel',
style: { color: '#888' },
onPress: () => {},
},
{
label: 'Remove',
style: { color: currentTheme.colors.error },
onPress: async () => {
setDeletingItemId(item.id);
try {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await storageService.removeAllWatchProgressForContent(item.id, item.type, { addBaseTombstone: true });
const traktService = TraktService.getInstance();
const isAuthed = await traktService.isAuthenticated();
if (isAuthed) {
let traktResult = false;
if (item.type === 'movie') {
traktResult = await traktService.removeMovieFromHistory(item.id);
} else if (item.type === 'series' && item.season !== undefined && item.episode !== undefined) {
traktResult = await traktService.removeEpisodeFromHistory(item.id, item.season, item.episode);
} else {
traktResult = await traktService.removeShowFromHistory(item.id);
}
}
const itemKey = `${item.type}:${item.id}`;
recentlyRemovedRef.current.add(itemKey);
await storageService.addContinueWatchingRemoved(item.id, item.type);
setTimeout(() => {
recentlyRemovedRef.current.delete(itemKey);
}, REMOVAL_IGNORE_DURATION);
setContinueWatchingItems(prev => prev.filter(i => i.id !== item.id));
} catch (error) {
// Continue even if removal fails
} finally {
setDeletingItemId(null);
}
},
},
]);
setAlertVisible(true);
}, [currentTheme.colors.error]);
// Memoized render function for continue watching items
const renderContinueWatchingItem = useCallback(({ item }: { item: ContinueWatchingItem }) => (
<TouchableOpacity
style={[
styles.wideContentItem,
{
backgroundColor: currentTheme.colors.elevation1,
borderColor: currentTheme.colors.border,
shadowColor: currentTheme.colors.black,
width: computedItemWidth,
height: computedItemHeight
}
]}
activeOpacity={0.8}
onPress={() => handleContentPress(item)}
onLongPress={() => handleLongPress(item)}
delayLongPress={800}
>
{/* Poster Image */}
<View style={[
styles.posterContainer,
{
width: isTV ? 100 : isLargeTablet ? 90 : isTablet ? 85 : 80
}
]}>
<FastImage
source={{
uri: item.poster || 'https://via.placeholder.com/300x450',
priority: FastImage.priority.high,
cache: FastImage.cacheControl.immutable
}}
style={styles.continueWatchingPoster}
resizeMode={FastImage.resizeMode.cover}
/>
{/* Delete Indicator Overlay */}
{deletingItemId === item.id && (
<View style={styles.deletingOverlay}>
<ActivityIndicator size="large" color="#FFFFFF" />
</View>
)}
</View>
{/* Content Details */}
<View style={[
styles.contentDetails,
{
padding: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}>
<View style={styles.titleRow}>
{(() => {
const isUpNext = item.type === 'series' && item.progress === 0;
return (
<View style={styles.titleRow}>
<Text
style={[
styles.contentTitle,
{
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16
}
]}
numberOfLines={1}
>
{item.name}
</Text>
{isUpNext && (
<View style={[
styles.progressBadge,
{
backgroundColor: currentTheme.colors.primary,
paddingHorizontal: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8,
paddingVertical: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3
}
]}>
<Text style={[
styles.progressText,
{ fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12 }
]}>Up Next</Text>
</View>
)}
</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,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 13
}
]}>
Season {item.season}
</Text>
{item.episodeTitle && (
<Text
style={[
styles.episodeTitle,
{
color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 13 : 12
}
]}
numberOfLines={1}
>
{item.episodeTitle}
</Text>
)}
</View>
);
} else {
return (
<Text style={[
styles.yearText,
{
color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 13
}
]}>
{item.year} {item.type === 'movie' ? 'Movie' : 'Series'}
</Text>
);
}
})()}
{/* Progress Bar */}
{item.progress > 0 && (
<View style={styles.wideProgressContainer}>
<View style={[
styles.wideProgressTrack,
{
height: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4
}
]}>
<View
style={[
styles.wideProgressBar,
{
width: `${item.progress}%`,
backgroundColor: currentTheme.colors.primary
}
]}
/>
</View>
<Text style={[
styles.progressLabel,
{
color: currentTheme.colors.textMuted,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 11
}
]}>
{Math.round(item.progress)}% watched
</Text>
</View>
)}
</View>
</TouchableOpacity>
), [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedItemWidth, computedItemHeight, isTV, isLargeTablet, isTablet]);
// Memoized key extractor
const keyExtractor = useCallback((item: ContinueWatchingItem) => `continue-${item.id}-${item.type}`, []);
// Memoized item separator
const ItemSeparator = useCallback(() => <View style={{ width: itemSpacing }} />, [itemSpacing]);
// If no continue watching items, don't render anything
if (continueWatchingItems.length === 0) {
return null;
}
return (
<Animated.View
style={styles.container}
entering={FadeIn.duration(350)}
>
<View style={[styles.header, { paddingHorizontal: horizontalPadding }]}>
<View style={styles.titleContainer}>
<Text style={[
styles.title,
{
color: currentTheme.colors.text,
fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24
}
]}>Continue Watching</Text>
<View style={[
styles.titleUnderline,
{
backgroundColor: currentTheme.colors.primary,
width: isTV ? 50 : isLargeTablet ? 45 : isTablet ? 40 : 40,
height: isTV ? 4 : isLargeTablet ? 3.5 : isTablet ? 3 : 3
}
]} />
</View>
</View>
<FlashList
data={continueWatchingItems}
renderItem={renderContinueWatchingItem}
keyExtractor={keyExtractor}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={[
styles.wideList,
{
paddingLeft: horizontalPadding,
paddingRight: horizontalPadding
}
]}
ItemSeparatorComponent={ItemSeparator}
onEndReachedThreshold={0.7}
onEndReached={() => {}}
removeClippedSubviews={true}
/>
<CustomAlert
visible={alertVisible}
title={alertTitle}
message={alertMessage}
actions={alertActions}
onClose={() => setAlertVisible(false)}
/>
</Animated.View>
);
});
const styles = StyleSheet.create({
container: {
marginBottom: 28,
paddingTop: 0,
marginTop: 12,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
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: {
paddingBottom: 8,
paddingTop: 4,
},
wideContentItem: {
width: 280,
height: 120,
flexDirection: 'row',
borderRadius: 14,
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: 14,
borderBottomLeftRadius: 14,
},
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, (prevProps, nextProps) => {
// This component has no props that would cause re-renders
return true;
});