817 lines
No EOL
27 KiB
TypeScript
817 lines
No EOL
27 KiB
TypeScript
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<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);
|
|
const [deletingItemId, setDeletingItemId] = useState<string | null>(null);
|
|
const longPressTimeoutRef = 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];
|
|
|
|
// 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<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 };
|
|
}
|
|
}
|
|
|
|
// 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 (
|
|
<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)}
|
|
onLongPress={() => handleLongPress(item)}
|
|
delayLongPress={800}
|
|
>
|
|
{/* 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}
|
|
/>
|
|
|
|
{/* Delete Indicator Overlay */}
|
|
{deletingItemId === item.id && (
|
|
<Animated.View
|
|
entering={FadeIn.duration(200)}
|
|
exiting={FadeOut.duration(200)}
|
|
style={styles.deletingOverlay}
|
|
>
|
|
<ActivityIndicator size="large" color="#FFFFFF" />
|
|
</Animated.View>
|
|
)}
|
|
</View>
|
|
|
|
{/* Content Details */}
|
|
<View style={styles.contentDetails}>
|
|
<View style={styles.titleRow}>
|
|
{(() => {
|
|
const isUpNext = item.progress === 0;
|
|
return (
|
|
<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}>{isUpNext ? 'Up Next' : `${Math.round(item.progress)}%`}</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 }]}>
|
|
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 */}
|
|
{item.progress > 0 && (
|
|
<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%',
|
|
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);
|