NuvioStreaming/src/components/metadata/SeriesContent.tsx
2026-01-07 16:38:04 +05:30

2329 lines
No EOL
79 KiB
TypeScript

import React, { useEffect, useState, useRef, useCallback, useMemo, memo } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme, FlatList, Modal, Pressable } from 'react-native';
import { useTranslation } from 'react-i18next';
import * as Haptics from 'expo-haptics';
import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { FlashList, FlashListRef } from '@shopify/flash-list';
import { useTheme } from '../../contexts/ThemeContext';
import { useSettings } from '../../hooks/useSettings';
import { Episode } from '../../types/metadata';
import { tmdbService, IMDbRatings } from '../../services/tmdbService';
import { storageService } from '../../services/storageService';
import { useFocusEffect } from '@react-navigation/native';
import Animated, { FadeIn, FadeOut, SlideInRight, SlideOutLeft } from 'react-native-reanimated';
import { TraktService } from '../../services/traktService';
import { watchedService } from '../../services/watchedService';
import { logger } from '../../utils/logger';
import { mmkvStorage } from '../../services/mmkvStorage';
// Enhanced responsive breakpoints for Seasons Section
const BREAKPOINTS = {
phone: 0,
tablet: 768,
largeTablet: 1024,
tv: 1440,
};
interface SeriesContentProps {
episodes: Episode[];
selectedSeason: number;
loadingSeasons: boolean;
onSeasonChange: (season: number) => void;
onSelectEpisode: (episode: Episode) => void;
groupedEpisodes?: { [seasonNumber: number]: Episode[] };
metadata?: { poster?: string; id?: string };
imdbId?: string; // IMDb ID for Trakt sync
}
// Add placeholder constant at the top
const DEFAULT_PLACEHOLDER = 'https://via.placeholder.com/300x450/1a1a1a/666666?text=No+Image';
const EPISODE_PLACEHOLDER = 'https://via.placeholder.com/500x280/1a1a1a/666666?text=No+Preview';
const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906';
const SeriesContentComponent: React.FC<SeriesContentProps> = ({
episodes,
selectedSeason,
loadingSeasons,
onSeasonChange,
onSelectEpisode,
groupedEpisodes = {},
metadata,
imdbId
}) => {
const { currentTheme } = useTheme();
const { settings } = useSettings();
const { t } = useTranslation();
const { width } = useWindowDimensions();
const isDarkMode = useColorScheme() === 'dark';
// 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 spacing and padding for seasons section
const horizontalPadding = useMemo(() => {
switch (deviceType) {
case 'tv':
return 32;
case 'largeTablet':
return 28;
case 'tablet':
return 24;
default:
return 16; // phone
}
}, [deviceType]);
// Match ThisWeekSection card sizing for horizontal episode cards
const horizontalCardWidth = useMemo(() => {
switch (deviceType) {
case 'tv':
return Math.min(deviceWidth * 0.25, 400);
case 'largeTablet':
return Math.min(deviceWidth * 0.35, 350);
case 'tablet':
return Math.min(deviceWidth * 0.46, 300);
default:
return width * 0.75;
}
}, [deviceType, deviceWidth, width]);
const horizontalCardHeight = useMemo(() => {
switch (deviceType) {
case 'tv':
return 280;
case 'largeTablet':
return 250;
case 'tablet':
return 220;
default:
return 180;
}
}, [deviceType]);
const horizontalItemSpacing = useMemo(() => {
switch (deviceType) {
case 'tv':
return 20;
case 'largeTablet':
return 18;
case 'tablet':
return 16;
default:
return 16;
}
}, [deviceType]);
// Enhanced season poster sizing
const seasonPosterWidth = useMemo(() => {
switch (deviceType) {
case 'tv':
return 140;
case 'largeTablet':
return 130;
case 'tablet':
return 120;
default:
return 100; // phone
}
}, [deviceType]);
const seasonPosterHeight = useMemo(() => {
switch (deviceType) {
case 'tv':
return 210;
case 'largeTablet':
return 195;
case 'tablet':
return 180;
default:
return 150; // phone
}
}, [deviceType]);
const seasonButtonSpacing = useMemo(() => {
switch (deviceType) {
case 'tv':
return 20;
case 'largeTablet':
return 18;
case 'tablet':
return 16;
default:
return 16; // phone
}
}, [deviceType]);
const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: { currentTime: number; duration: number; lastUpdated: number } }>({});
// Delay item entering animations to avoid FlashList initial layout glitches
const [enableItemAnimations, setEnableItemAnimations] = useState(false);
// Local TMDB hydration for rating/runtime when addon (Cinemeta) lacks these
const [tmdbEpisodeOverrides, setTmdbEpisodeOverrides] = useState<{ [epKey: string]: { vote_average?: number; runtime?: number; still_path?: string } }>({});
// IMDb ratings for episodes - using a map for O(1) lookups instead of array searches
const [imdbRatingsMap, setImdbRatingsMap] = useState<{ [key: string]: number }>({});
// Add state for season view mode (persists for current show across navigation)
const [seasonViewMode, setSeasonViewMode] = useState<'posters' | 'text'>('posters');
// View mode state (no animations)
const [posterViewVisible, setPosterViewVisible] = useState(true);
const [textViewVisible, setTextViewVisible] = useState(false);
// Episode action menu state
const [episodeActionMenuVisible, setEpisodeActionMenuVisible] = useState(false);
const [selectedEpisodeForAction, setSelectedEpisodeForAction] = useState<Episode | null>(null);
const [markingAsWatched, setMarkingAsWatched] = useState(false);
// Add refs for the scroll views
const seasonScrollViewRef = useRef<ScrollView | null>(null);
const episodeScrollViewRef = useRef<FlashListRef<Episode>>(null);
const horizontalEpisodeScrollViewRef = useRef<FlatList<Episode>>(null);
// Load saved global view mode preference when component mounts
useEffect(() => {
const loadViewModePreference = async () => {
try {
const savedMode = await mmkvStorage.getItem('global_season_view_mode');
if (savedMode === 'text' || savedMode === 'posters') {
setSeasonViewMode(savedMode);
if (__DEV__) console.log('[SeriesContent] Loaded global view mode:', savedMode);
}
} catch (error) {
if (__DEV__) console.log('[SeriesContent] Error loading global view mode preference:', error);
}
};
loadViewModePreference();
}, []);
// Initialize view mode visibility based on current view mode
useEffect(() => {
if (seasonViewMode === 'text') {
setPosterViewVisible(false);
setTextViewVisible(true);
} else {
setPosterViewVisible(true);
setTextViewVisible(false);
}
}, [seasonViewMode]);
// Update view mode without animations
const updateViewMode = (newMode: 'posters' | 'text') => {
setSeasonViewMode(newMode);
mmkvStorage.setItem('global_season_view_mode', newMode).catch((error: any) => {
if (__DEV__) console.log('[SeriesContent] Error saving global view mode preference:', error);
});
};
// Add refs for the scroll views
const loadEpisodesProgress = async () => {
if (!metadata?.id) return;
const allProgress = await storageService.getAllWatchProgress();
const progress: { [key: string]: { currentTime: number; duration: number; lastUpdated: number } } = {};
episodes.forEach(episode => {
const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`;
const key = `series:${metadata.id}:${episodeId}`;
if (allProgress[key]) {
progress[episodeId] = {
currentTime: allProgress[key].currentTime,
duration: allProgress[key].duration,
lastUpdated: allProgress[key].lastUpdated
};
}
});
// ---------------- Trakt watched-history integration ----------------
try {
const traktService = TraktService.getInstance();
const isAuthed = await traktService.isAuthenticated();
if (isAuthed && metadata?.id) {
// Fetch multiple pages to ensure we get all episodes for shows with many seasons
// Each page has up to 100 items by default, fetch enough to cover ~12+ seasons
let allHistoryItems: any[] = [];
const pageLimit = 10; // Fetch up to 10 pages (max 1000 items) to cover extensive libraries
for (let page = 1; page <= pageLimit; page++) {
const historyItems = await traktService.getWatchedEpisodesHistory(page, 100);
if (!historyItems || historyItems.length === 0) {
break; // No more items to fetch
}
allHistoryItems = allHistoryItems.concat(historyItems);
}
allHistoryItems.forEach(item => {
if (item.type !== 'episode') return;
const showImdb = item.show?.ids?.imdb ? `tt${item.show.ids.imdb.replace(/^tt/, '')}` : null;
if (!showImdb || showImdb !== metadata.id) return;
const season = item.episode?.season;
const epNum = item.episode?.number;
if (season === undefined || epNum === undefined) return;
const episodeId = `${metadata.id}:${season}:${epNum}`;
const watchedAt = new Date(item.watched_at).getTime();
// Mark as 100% completed (use 1/1 to avoid divide-by-zero)
const traktProgressEntry = {
currentTime: 1,
duration: 1,
lastUpdated: watchedAt,
};
const existing = progress[episodeId];
const existingPercent = existing ? (existing.currentTime / existing.duration) * 100 : 0;
// Prefer local progress if it is already >=85%; otherwise use Trakt data
if (!existing || existingPercent < 85) {
progress[episodeId] = traktProgressEntry;
}
});
}
} catch (err) {
logger.error('[SeriesContent] Failed to merge Trakt history:', err);
}
setEpisodeProgress(progress);
};
// Function to find and scroll to the most recently watched episode
const scrollToMostRecentEpisode = () => {
if (!metadata?.id || !settings?.episodeLayoutStyle || settings.episodeLayoutStyle !== 'horizontal') {
return;
}
const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || [];
if (currentSeasonEpisodes.length === 0) {
return;
}
// Find the most recently watched episode in the current season
let mostRecentEpisodeIndex = -1;
let mostRecentTimestamp = 0;
let mostRecentEpisodeName = '';
currentSeasonEpisodes.forEach((episode, index) => {
const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`;
const progress = episodeProgress[episodeId];
if (progress && progress.lastUpdated > mostRecentTimestamp && progress.currentTime > 0) {
mostRecentTimestamp = progress.lastUpdated;
mostRecentEpisodeIndex = index;
mostRecentEpisodeName = episode.name;
}
});
// Scroll to the most recently watched episode if found
if (mostRecentEpisodeIndex >= 0) {
setTimeout(() => {
if (horizontalEpisodeScrollViewRef.current) {
// Use scrollToIndex which automatically uses getItemLayout for accurate positioning
horizontalEpisodeScrollViewRef.current.scrollToIndex({
index: mostRecentEpisodeIndex,
animated: true,
viewPosition: 0 // Align to start of card for precise positioning
});
}
}, 500); // Delay to ensure the season has loaded
}
};
// Initial load of watch progress
useEffect(() => {
loadEpisodesProgress();
}, [episodes, metadata?.id]);
// Fetch IMDb ratings for the show
useEffect(() => {
const fetchIMDbRatings = async () => {
try {
if (!metadata?.id) {
logger.log('[SeriesContent] No metadata.id, skipping IMDb ratings fetch');
return;
}
logger.log('[SeriesContent] Starting IMDb ratings fetch for metadata.id:', metadata.id);
// Resolve TMDB show id
let tmdbShowId: number | null = null;
if (metadata.id.startsWith('tmdb:')) {
tmdbShowId = parseInt(metadata.id.split(':')[1], 10);
logger.log('[SeriesContent] Extracted TMDB ID from metadata.id:', tmdbShowId);
} else if (metadata.id.startsWith('tt')) {
logger.log('[SeriesContent] Found IMDb ID, looking up TMDB ID...');
tmdbShowId = await tmdbService.findTMDBIdByIMDB(metadata.id);
logger.log('[SeriesContent] TMDB ID lookup result:', tmdbShowId);
} else {
logger.log('[SeriesContent] metadata.id does not start with tmdb: or tt:', metadata.id);
}
if (!tmdbShowId) {
logger.warn('[SeriesContent] Could not resolve TMDB show ID, skipping IMDb ratings fetch');
return;
}
logger.log('[SeriesContent] Fetching IMDb ratings for TMDB ID:', tmdbShowId);
// Fetch IMDb ratings for all seasons
const ratings = await tmdbService.getIMDbRatings(tmdbShowId);
if (ratings) {
logger.log('[SeriesContent] IMDb ratings fetched successfully. Seasons:', ratings.length);
// Create a lookup map for O(1) access: key format "season:episode" -> rating
const ratingsMap: { [key: string]: number } = {};
ratings.forEach(season => {
if (season.episodes) {
season.episodes.forEach(episode => {
const key = `${episode.season_number}:${episode.episode_number}`;
if (episode.vote_average) {
ratingsMap[key] = episode.vote_average;
}
});
}
});
logger.log('[SeriesContent] IMDb ratings map created with', Object.keys(ratingsMap).length, 'episodes');
setImdbRatingsMap(ratingsMap);
} else {
logger.warn('[SeriesContent] IMDb ratings fetch returned null/undefined');
}
} catch (err) {
logger.error('[SeriesContent] Failed to fetch IMDb ratings:', err);
}
};
fetchIMDbRatings();
}, [metadata?.id]);
// Hydrate TMDB rating/runtime for current season episodes if missing
useEffect(() => {
const hydrateFromTmdb = async () => {
try {
if (!metadata?.id || !selectedSeason) return;
// Respect settings: skip TMDB enrichment when disabled
if (!settings?.enrichMetadataWithTMDB) return;
const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || [];
if (currentSeasonEpisodes.length === 0) return;
// Check if hydration is needed
const needsHydration = currentSeasonEpisodes.some(ep => !(ep as any).runtime || !(ep as any).vote_average);
if (!needsHydration) return;
// Resolve TMDB show id
let tmdbShowId: number | null = null;
if (metadata.id.startsWith('tmdb:')) {
tmdbShowId = parseInt(metadata.id.split(':')[1], 10);
} else if (metadata.id.startsWith('tt')) {
tmdbShowId = await tmdbService.findTMDBIdByIMDB(metadata.id);
}
if (!tmdbShowId) return;
// Fetch all episodes from TMDB and build override map for the current season
const all = await tmdbService.getAllEpisodes(tmdbShowId);
const overrides: { [k: string]: { vote_average?: number; runtime?: number; still_path?: string } } = {};
const seasonEpisodes = all?.[selectedSeason] || [];
seasonEpisodes.forEach((tmdbEp: any) => {
const key = `${metadata.id}:${tmdbEp.season_number}:${tmdbEp.episode_number}`;
overrides[key] = {
vote_average: tmdbEp.vote_average,
runtime: tmdbEp.runtime,
still_path: tmdbEp.still_path,
};
});
if (Object.keys(overrides).length > 0) {
setTmdbEpisodeOverrides(prev => ({ ...prev, ...overrides }));
}
} catch (err) {
logger.error('[SeriesContent] TMDB hydration failed:', err);
}
};
hydrateFromTmdb();
}, [metadata?.id, selectedSeason, groupedEpisodes, settings?.enrichMetadataWithTMDB]);
// Enable item animations shortly after mount to avoid initial overlap/glitch
useEffect(() => {
const timer = setTimeout(() => setEnableItemAnimations(true), 200);
return () => clearTimeout(timer);
}, []);
// Refresh watch progress when screen comes into focus
useFocusEffect(
React.useCallback(() => {
loadEpisodesProgress();
}, [episodes, metadata?.id])
);
// Memory optimization: Cleanup on unmount
useEffect(() => {
return () => {
// Clear any pending timeouts
if (__DEV__) console.log('[SeriesContent] Component unmounted, cleaning up memory');
// Force garbage collection if available (development only)
if (__DEV__ && global.gc) {
global.gc();
}
};
}, []);
// Track previous season to only scroll when it actually changes
const previousSeasonRef = React.useRef<number | null>(null);
// Add effect to scroll to selected season (only when season changes, not on every groupedEpisodes update)
useEffect(() => {
if (selectedSeason && seasonScrollViewRef.current && Object.keys(groupedEpisodes).length > 0) {
// Only scroll if the season actually changed (not just groupedEpisodes update)
if (previousSeasonRef.current === selectedSeason) {
return; // Season didn't change, don't scroll
}
previousSeasonRef.current = selectedSeason;
// Find the index of the selected season
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b);
const selectedIndex = seasons.findIndex(season => season === selectedSeason);
if (selectedIndex !== -1) {
// Wait a small amount of time for layout to be ready
setTimeout(() => {
if (seasonScrollViewRef.current && typeof (seasonScrollViewRef.current as any).scrollToOffset === 'function') {
(seasonScrollViewRef.current as any).scrollToOffset({
offset: selectedIndex * 116, // 100px width + 16px margin
animated: true
});
}
}, 300);
}
}
}, [selectedSeason, groupedEpisodes]);
// Add effect to scroll to most recently watched episode when season changes or progress loads
useEffect(() => {
if (Object.keys(episodeProgress).length > 0 && selectedSeason && settings?.episodeLayoutStyle) {
scrollToMostRecentEpisode();
}
}, [selectedSeason, episodeProgress, settings?.episodeLayoutStyle, groupedEpisodes]);
// Helper function to get IMDb rating for an episode - O(1) lookup using map
const getIMDbRating = useCallback((seasonNumber: number, episodeNumber: number): number | null => {
const key = `${seasonNumber}:${episodeNumber}`;
const rating = imdbRatingsMap[key];
return rating ?? null;
}, [imdbRatingsMap]);
// Handle long press on episode to show action menu
const handleEpisodeLongPress = useCallback((episode: Episode) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
setSelectedEpisodeForAction(episode);
setEpisodeActionMenuVisible(true);
}, []);
// Check if an episode is watched (>= 85% progress)
const isEpisodeWatched = useCallback((episode: Episode): boolean => {
const episodeId = episode.stremioId || `${metadata?.id}:${episode.season_number}:${episode.episode_number}`;
const progress = episodeProgress[episodeId];
if (!progress) return false;
const progressPercent = (progress.currentTime / progress.duration) * 100;
return progressPercent >= 85;
}, [episodeProgress, metadata?.id]);
// Mark episode as watched
const handleMarkAsWatched = useCallback(async () => {
if (!selectedEpisodeForAction || !metadata?.id) return;
const episode = selectedEpisodeForAction; // Capture for closure
const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`;
// 1. Optimistic UI Update
setEpisodeProgress(prev => ({
...prev,
[episodeId]: { currentTime: 1, duration: 1, lastUpdated: Date.now() } // 100% progress
}));
// 2. Instant Feedback
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
setEpisodeActionMenuVisible(false);
setSelectedEpisodeForAction(null);
// 3. Background Async Operation
const showImdbId = imdbId || metadata.id;
try {
const result = await watchedService.markEpisodeAsWatched(
showImdbId,
metadata.id,
episode.season_number,
episode.episode_number
);
// Reload to ensure consistency (e.g. if optimistic update was slightly off or for other effects)
// But we don't strictly *need* to wait for this to update UI
loadEpisodesProgress();
logger.log(`[SeriesContent] Mark as watched result:`, result);
} catch (error) {
logger.error('[SeriesContent] Error marking episode as watched:', error);
// Ideally revert state here, but simple error logging is often enough for non-critical non-transactional actions
loadEpisodesProgress(); // Reload to revert to source of truth
}
}, [selectedEpisodeForAction, metadata?.id, imdbId]);
// Mark episode as unwatched
const handleMarkAsUnwatched = useCallback(async () => {
if (!selectedEpisodeForAction || !metadata?.id) return;
const episode = selectedEpisodeForAction;
const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`;
// 1. Optimistic UI Update - Remove from progress map
setEpisodeProgress(prev => {
const newState = { ...prev };
delete newState[episodeId];
return newState;
});
// 2. Instant Feedback
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
setEpisodeActionMenuVisible(false);
setSelectedEpisodeForAction(null);
// 3. Background Async Operation
const showImdbId = imdbId || metadata.id;
try {
const result = await watchedService.unmarkEpisodeAsWatched(
showImdbId,
metadata.id,
episode.season_number,
episode.episode_number
);
loadEpisodesProgress(); // Sync with source of truth
logger.log(`[SeriesContent] Unmark watched result:`, result);
} catch (error) {
logger.error('[SeriesContent] Error unmarking episode as watched:', error);
loadEpisodesProgress(); // Revert
}
}, [selectedEpisodeForAction, metadata?.id, imdbId]);
// Mark entire season as watched
const handleMarkSeasonAsWatched = useCallback(async () => {
if (!metadata?.id) return;
// Capture values
const currentSeason = selectedSeason;
const seasonEpisodes = groupedEpisodes[currentSeason] || [];
const episodeNumbers = seasonEpisodes.map(ep => ep.episode_number);
// 1. Optimistic UI Update
setEpisodeProgress(prev => {
const next = { ...prev };
seasonEpisodes.forEach(ep => {
const id = ep.stremioId || `${metadata.id}:${ep.season_number}:${ep.episode_number}`;
next[id] = { currentTime: 1, duration: 1, lastUpdated: Date.now() };
});
return next;
});
// 2. Instant Feedback
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
setEpisodeActionMenuVisible(false);
setSelectedEpisodeForAction(null);
// 3. Background Async Operation
const showImdbId = imdbId || metadata.id;
try {
const result = await watchedService.markSeasonAsWatched(
showImdbId,
metadata.id,
currentSeason,
episodeNumbers
);
// Re-sync with source of truth
loadEpisodesProgress();
logger.log(`[SeriesContent] Mark season as watched result:`, result);
} catch (error) {
logger.error('[SeriesContent] Error marking season as watched:', error);
loadEpisodesProgress(); // Revert
}
}, [metadata?.id, imdbId, selectedSeason, groupedEpisodes]);
// Check if entire season is watched
const isSeasonWatched = useCallback((): boolean => {
const seasonEpisodes = groupedEpisodes[selectedSeason] || [];
if (seasonEpisodes.length === 0) return false;
return seasonEpisodes.every(ep => {
const episodeId = ep.stremioId || `${metadata?.id}:${ep.season_number}:${ep.episode_number}`;
const progress = episodeProgress[episodeId];
if (!progress) return false;
const progressPercent = (progress.currentTime / progress.duration) * 100;
return progressPercent >= 85;
});
}, [groupedEpisodes, selectedSeason, episodeProgress, metadata?.id]);
// Unmark entire season as watched
const handleMarkSeasonAsUnwatched = useCallback(async () => {
if (!metadata?.id) return;
// Capture values
const currentSeason = selectedSeason;
const seasonEpisodes = groupedEpisodes[currentSeason] || [];
const episodeNumbers = seasonEpisodes.map(ep => ep.episode_number);
// 1. Optimistic UI Update - Remove all episodes of season from progress
setEpisodeProgress(prev => {
const next = { ...prev };
seasonEpisodes.forEach(ep => {
const id = ep.stremioId || `${metadata.id}:${ep.season_number}:${ep.episode_number}`;
delete next[id];
});
return next;
});
// 2. Instant Feedback
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
setEpisodeActionMenuVisible(false);
setSelectedEpisodeForAction(null);
// 3. Background Async Operation
const showImdbId = imdbId || metadata.id;
try {
const result = await watchedService.unmarkSeasonAsWatched(
showImdbId,
metadata.id,
currentSeason,
episodeNumbers
);
// Re-sync
loadEpisodesProgress();
logger.log(`[SeriesContent] Unmark season as watched result:`, result);
} catch (error) {
logger.error('[SeriesContent] Error unmarking season as watched:', error);
loadEpisodesProgress(); // Revert
}
}, [metadata?.id, imdbId, selectedSeason, groupedEpisodes]);
// Close action menu
const closeEpisodeActionMenu = useCallback(() => {
setEpisodeActionMenuVisible(false);
setSelectedEpisodeForAction(null);
}, []);
if (loadingSeasons) {
return (
<View style={styles.centeredContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={[styles.centeredText, { color: currentTheme.colors.text }]}>{t('metadata.loading_episodes')}</Text>
</View>
);
}
if (episodes.length === 0) {
return (
<View style={styles.centeredContainer}>
<MaterialIcons name="error-outline" size={48} color={currentTheme.colors.textMuted} />
<Text style={[styles.centeredText, { color: currentTheme.colors.text }]}>{t('metadata.no_episodes_available')}</Text>
</View>
);
}
const renderSeasonSelector = () => {
// Show selector if we have grouped episodes data or can derive from episodes
if (!groupedEpisodes || Object.keys(groupedEpisodes).length <= 1) {
return null;
}
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => {
if (a === 0) return 1;
if (b === 0) return -1;
return a - b;
});
return (
<View style={[
styles.seasonSelectorWrapper,
{ paddingHorizontal: horizontalPadding }
]}>
<View style={[
styles.seasonSelectorHeader,
{
marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}>
<Text style={[
styles.seasonSelectorTitle,
{
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 18
}
]}>{t('metadata.seasons')}</Text>
{/* Dropdown Toggle Button */}
<TouchableOpacity
style={[
styles.seasonViewToggle,
{
backgroundColor: seasonViewMode === 'posters'
? currentTheme.colors.elevation2
: currentTheme.colors.elevation3,
borderColor: seasonViewMode === 'posters'
? 'rgba(255,255,255,0.2)'
: 'rgba(255,255,255,0.3)',
paddingHorizontal: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8,
paddingVertical: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4,
borderRadius: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 6 : 6
}
]}
onPress={() => {
const newMode = seasonViewMode === 'posters' ? 'text' : 'posters';
updateViewMode(newMode);
if (__DEV__) console.log('[SeriesContent] View mode changed to:', newMode, 'Current ref value:', seasonViewMode);
}}
activeOpacity={0.7}
>
<Text style={[
styles.seasonViewToggleText,
{
color: seasonViewMode === 'posters'
? currentTheme.colors.mediumEmphasis
: currentTheme.colors.highEmphasis,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 12
}
]}>
{seasonViewMode === 'posters' ? 'Posters' : 'Text'}
</Text>
</TouchableOpacity>
</View>
<FlatList
ref={seasonScrollViewRef as React.RefObject<FlatList<any>>}
data={seasons}
horizontal
showsHorizontalScrollIndicator={false}
style={styles.seasonSelectorContainer}
contentContainerStyle={[
styles.seasonSelectorContent,
{
paddingBottom: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8
}
]}
initialNumToRender={5}
maxToRenderPerBatch={5}
windowSize={3}
renderItem={({ item: season }) => {
const seasonEpisodes = groupedEpisodes[season] || [];
// Get season poster URL (needed for both views)
let seasonPoster = DEFAULT_PLACEHOLDER;
if (seasonEpisodes[0]?.season_poster_path) {
const tmdbUrl = tmdbService.getImageUrl(seasonEpisodes[0].season_poster_path, 'original');
if (tmdbUrl) seasonPoster = tmdbUrl;
} else if (metadata?.poster) {
seasonPoster = metadata.poster;
}
if (seasonViewMode === 'text') {
// Text-only view
return (
<View
key={season}
style={{ opacity: textViewVisible ? 1 : 0 }}
>
<TouchableOpacity
style={[
styles.seasonTextButton,
{
marginRight: seasonButtonSpacing,
paddingVertical: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12,
paddingHorizontal: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
borderRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
},
selectedSeason === season && styles.selectedSeasonTextButton
]}
onPress={() => onSeasonChange(season)}
>
<Text style={[
styles.seasonTextButtonText,
isTablet && styles.seasonTextButtonTextTablet,
{ color: currentTheme.colors.highEmphasis },
selectedSeason === season && [
styles.selectedSeasonTextButtonText,
isTablet && styles.selectedSeasonTextButtonTextTablet,
{ color: currentTheme.colors.highEmphasis }
]
]} numberOfLines={1}>
{season === 0 ? t('metadata.specials') : t('metadata.season_number', { number: season })}
</Text>
</TouchableOpacity>
</View>
);
}
// Poster view (current implementation)
return (
<View
key={season}
style={{ opacity: posterViewVisible ? 1 : 0 }}
>
<TouchableOpacity
style={[
styles.seasonButton,
{
marginRight: seasonButtonSpacing,
width: seasonPosterWidth
},
selectedSeason === season && [styles.selectedSeasonButton, { borderColor: currentTheme.colors.primary }]
]}
onPress={() => onSeasonChange(season)}
>
<View style={[
styles.seasonPosterContainer,
{
width: seasonPosterWidth,
height: seasonPosterHeight,
borderRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 8,
marginBottom: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8
}
]}>
<FastImage
source={{ uri: seasonPoster }}
style={styles.seasonPoster}
resizeMode={FastImage.resizeMode.cover}
/>
{selectedSeason === season && (
<View style={[
styles.selectedSeasonIndicator,
{
backgroundColor: currentTheme.colors.primary,
height: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4
}
]} />
)}
</View>
<Text
style={[
styles.seasonButtonText,
{
color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14
},
selectedSeason === season && [
styles.selectedSeasonButtonText,
{ color: currentTheme.colors.primary }
]
]}
>
{season === 0 ? t('metadata.specials') : t('metadata.season_number', { number: season })}
</Text>
</TouchableOpacity>
</View>
);
}}
keyExtractor={season => season.toString()}
/>
</View>
);
};
// Vertical layout episode card (traditional)
const renderVerticalEpisodeCard = (episode: Episode) => {
// Resolve episode image with addon-first logic
const resolveEpisodeImage = (): string => {
const candidates: Array<string | undefined | null> = [
// Add-on common fields
(episode as any).thumbnail,
(episode as any).image,
(episode as any).thumb,
(episode as any)?.images?.still,
episode.still_path,
];
for (const cand of candidates) {
if (!cand) continue;
if (typeof cand === 'string' && (cand.startsWith('http://') || cand.startsWith('https://'))) {
return cand;
}
// TMDB relative paths only when enrichment is enabled
if (typeof cand === 'string' && cand.startsWith('/') && settings?.enrichMetadataWithTMDB) {
const tmdbUrl = tmdbService.getImageUrl(cand, 'original');
if (tmdbUrl) return tmdbUrl;
}
}
return metadata?.poster || EPISODE_PLACEHOLDER;
};
let episodeImage = resolveEpisodeImage();
const episodeNumber = typeof episode.episode_number === 'number' ? episode.episode_number.toString() : '';
const seasonNumber = typeof episode.season_number === 'number' ? episode.season_number.toString() : '';
const episodeString = seasonNumber && episodeNumber ? `S${seasonNumber.padStart(2, '0')}E${episodeNumber.padStart(2, '0')}` : '';
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
};
const formatRuntime = (runtime: number) => {
if (!runtime) return null;
const hours = Math.floor(runtime / 60);
const minutes = runtime % 60;
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
};
// Get episode progress
const episodeId = episode.stremioId || `${metadata?.id}:${episode.season_number}:${episode.episode_number}`;
const tmdbOverride = tmdbEpisodeOverrides[`${metadata?.id}:${episode.season_number}:${episode.episode_number}`];
// Prioritize IMDb rating, fallback to TMDB
const imdbRating = getIMDbRating(episode.season_number, episode.episode_number);
const tmdbRating = tmdbOverride?.vote_average ?? episode.vote_average;
const effectiveVote = imdbRating ?? tmdbRating ?? 0;
const isImdbRating = imdbRating !== null;
const effectiveRuntime = tmdbOverride?.runtime ?? (episode as any).runtime;
if (!episode.still_path && tmdbOverride?.still_path) {
const tmdbUrl = tmdbService.getImageUrl(tmdbOverride.still_path, 'original');
if (tmdbUrl) episodeImage = tmdbUrl;
}
const progress = episodeProgress[episodeId];
const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0;
// Don't show progress bar if episode is complete (>= 85%)
const showProgress = progress && progressPercent < 85;
return (
<TouchableOpacity
key={episode.id}
style={[
styles.episodeCardVertical,
{
backgroundColor: currentTheme.colors.elevation2,
borderRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
marginBottom: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
height: isTV ? 200 : isLargeTablet ? 180 : isTablet ? 160 : 120
}
]}
onPress={() => onSelectEpisode(episode)}
onLongPress={() => handleEpisodeLongPress(episode)}
delayLongPress={400}
activeOpacity={0.7}
>
<View style={[
styles.episodeImageContainer,
{
width: isTV ? 200 : isLargeTablet ? 180 : isTablet ? 160 : 120,
height: isTV ? 200 : isLargeTablet ? 180 : isTablet ? 160 : 120
}
]}>
<FastImage
source={{ uri: episodeImage }}
style={styles.episodeImage}
resizeMode={FastImage.resizeMode.cover}
/>
<View style={[
styles.episodeNumberBadge,
{
paddingHorizontal: isTV ? 8 : isLargeTablet ? 7 : isTablet ? 6 : 6,
paddingVertical: isTV ? 4 : isLargeTablet ? 3 : isTablet ? 2 : 2,
borderRadius: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4
}
]}>
<Text style={[
styles.episodeNumberText,
{
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11,
fontWeight: '600'
}
]}>{episodeString}</Text>
</View>
{showProgress && (
<View style={styles.progressBarContainer}>
<View
style={[
styles.progressBar,
{ width: `${progressPercent}%`, backgroundColor: currentTheme.colors.primary }
]}
/>
</View>
)}
{progressPercent >= 85 && (
<View style={[
styles.completedBadge,
{
backgroundColor: currentTheme.colors.primary,
width: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20,
height: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20,
borderRadius: isTV ? 12 : isLargeTablet ? 11 : isTablet ? 10 : 10
}
]}>
<MaterialIcons name="check" size={isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12} color={currentTheme.colors.white} />
</View>
)}
{(!progress || progressPercent === 0) && (
<View style={{
position: 'absolute',
top: 8,
left: 8,
width: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20,
height: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20,
borderRadius: isTV ? 12 : isLargeTablet ? 11 : isTablet ? 10 : 10,
borderWidth: 2,
borderStyle: 'dashed',
borderColor: currentTheme.colors.textMuted,
opacity: 0.85,
}} />
)}
</View>
<View style={[
styles.episodeInfo,
{
paddingLeft: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 12,
flex: 1,
justifyContent: 'center'
}
]}>
<View style={[
styles.episodeHeader,
{
marginBottom: isTV ? 8 : isLargeTablet ? 6 : isTablet ? 6 : 4
}
]}>
<Text style={[
styles.episodeTitle,
{
color: currentTheme.colors.text,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15,
lineHeight: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 18,
marginBottom: isTV ? 4 : isLargeTablet ? 3 : isTablet ? 2 : 2
}
]} numberOfLines={isLargeScreen ? 3 : 2}>
{episode.name}
</Text>
<View style={[
styles.episodeMetadata,
{
gap: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8,
flexWrap: 'wrap'
}
]}>
{effectiveRuntime && (
<View style={styles.runtimeContainer}>
<MaterialIcons name="schedule" size={isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14} color={currentTheme.colors.textMuted} />
<Text style={[
styles.runtimeText,
{
color: currentTheme.colors.textMuted,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 13
}
]}>
{formatRuntime(effectiveRuntime)}
</Text>
</View>
)}
{effectiveVote > 0 && (
<View style={styles.ratingContainer}>
{isImdbRating ? (
<>
<Text style={[
styles.ratingText,
{
color: '#F5C518',
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 13,
fontWeight: '600'
}
]}>
{effectiveVote.toFixed(1)}
</Text>
</>
) : (
<>
<FastImage
source={{ uri: TMDB_LOGO }}
style={[
styles.tmdbLogo,
{
width: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 20 : 20,
height: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14
}
]}
resizeMode={FastImage.resizeMode.contain}
/>
<Text style={[
styles.ratingText,
{
color: currentTheme.colors.textMuted,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 13
}
]}>
{effectiveVote.toFixed(1)}
</Text>
</>
)}
</View>
)}
{episode.air_date && (
<Text style={[
styles.airDateText,
{
color: currentTheme.colors.textMuted,
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 12 : 12
}
]}>
{formatDate(episode.air_date)}
</Text>
)}
</View>
</View>
<Text style={[
styles.episodeOverview,
{
color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 13,
lineHeight: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 20 : 18
}
]} numberOfLines={isLargeScreen ? 4 : isTablet ? 3 : 2}>
{(episode.overview || (episode as any).description || (episode as any).plot || (episode as any).synopsis || 'No description available')}
</Text>
</View>
</TouchableOpacity>
);
};
// Horizontal layout episode card (Netflix-style)
const renderHorizontalEpisodeCard = (episode: Episode) => {
const resolveEpisodeImage = (): string => {
const candidates: Array<string | undefined | null> = [
(episode as any).thumbnail,
(episode as any).image,
(episode as any).thumb,
(episode as any)?.images?.still,
episode.still_path,
];
for (const cand of candidates) {
if (!cand) continue;
if (typeof cand === 'string' && (cand.startsWith('http://') || cand.startsWith('https://'))) {
return cand;
}
if (typeof cand === 'string' && cand.startsWith('/') && settings?.enrichMetadataWithTMDB) {
const tmdbUrl = tmdbService.getImageUrl(cand, 'original');
if (tmdbUrl) return tmdbUrl;
}
}
return metadata?.poster || EPISODE_PLACEHOLDER;
};
let episodeImage = resolveEpisodeImage();
const episodeNumber = typeof episode.episode_number === 'number' ? episode.episode_number.toString() : '';
const seasonNumber = typeof episode.season_number === 'number' ? episode.season_number.toString() : '';
const episodeString = seasonNumber && episodeNumber ? `EPISODE ${episodeNumber}` : '';
const formatRuntime = (runtime: number) => {
if (!runtime) return null;
const hours = Math.floor(runtime / 60);
const minutes = runtime % 60;
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
};
// Get episode progress
const episodeId = episode.stremioId || `${metadata?.id}:${episode.season_number}:${episode.episode_number}`;
const tmdbOverride = tmdbEpisodeOverrides[`${metadata?.id}:${episode.season_number}:${episode.episode_number}`];
// Prioritize IMDb rating, fallback to TMDB
const imdbRating = getIMDbRating(episode.season_number, episode.episode_number);
const tmdbRating = tmdbOverride?.vote_average ?? episode.vote_average;
const effectiveVote = imdbRating ?? tmdbRating ?? 0;
const isImdbRating = imdbRating !== null;
const effectiveRuntime = tmdbOverride?.runtime ?? (episode as any).runtime;
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
};
const progress = episodeProgress[episodeId];
const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0;
// Don't show progress bar if episode is complete (>= 85%)
const showProgress = progress && progressPercent < 85;
return (
<TouchableOpacity
key={episode.id}
style={[
styles.episodeCardHorizontal,
{
borderRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
height: horizontalCardHeight,
elevation: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 8,
shadowOpacity: isTV ? 0.4 : isLargeTablet ? 0.35 : isTablet ? 0.3 : 0.3,
shadowRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 8
},
// Gradient border styling
{
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.12)',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
}
]}
onPress={() => onSelectEpisode(episode)}
onLongPress={() => handleEpisodeLongPress(episode)}
delayLongPress={400}
activeOpacity={0.85}
>
{/* Solid outline replaces gradient border */}
{/* Background Image */}
<FastImage
source={{ uri: episodeImage }}
style={styles.episodeBackgroundImage}
resizeMode={FastImage.resizeMode.cover}
/>
{/* Standard Gradient Overlay */}
<LinearGradient
colors={[
'rgba(0,0,0,0.05)',
'rgba(0,0,0,0.2)',
'rgba(0,0,0,0.6)',
'rgba(0,0,0,0.85)',
'rgba(0,0,0,0.95)'
]}
locations={[0, 0.2, 0.5, 0.8, 1]}
style={styles.episodeGradient}
>
{/* Content Container */}
<View style={[
styles.episodeContent,
{
padding: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 12,
paddingBottom: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 16
}
]}>
{/* Episode Number Badge */}
<View style={[
styles.episodeNumberBadgeHorizontal,
{
paddingHorizontal: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 6 : 6,
paddingVertical: isTV ? 5 : isLargeTablet ? 4 : isTablet ? 3 : 3,
borderRadius: isTV ? 8 : isLargeTablet ? 6 : isTablet ? 4 : 4,
marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 6 : 6
}
]}>
<Text style={[
styles.episodeNumberHorizontal,
{
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 10,
fontWeight: isTV ? '700' : isLargeTablet ? '700' : isTablet ? '600' : '600'
}
]}>{episodeString}</Text>
</View>
{/* Episode Title */}
<Text style={[
styles.episodeTitleHorizontal,
{
fontSize: isTV ? 20 : isLargeTablet ? 19 : isTablet ? 18 : 15,
fontWeight: isTV ? '800' : isLargeTablet ? '800' : isTablet ? '700' : '700',
lineHeight: isTV ? 26 : isLargeTablet ? 24 : isTablet ? 22 : 18,
marginBottom: isTV ? 8 : isLargeTablet ? 6 : isTablet ? 4 : 4
}
]} numberOfLines={2}>
{episode.name}
</Text>
{/* Episode Description */}
<Text style={[
styles.episodeDescriptionHorizontal,
{
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 12,
lineHeight: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 16,
marginBottom: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8,
opacity: isTV ? 0.95 : isLargeTablet ? 0.9 : isTablet ? 0.9 : 0.9
}
]} numberOfLines={isLargeScreen ? 4 : 3}>
{(episode.overview || (episode as any).description || (episode as any).plot || (episode as any).synopsis || 'No description available')}
</Text>
{/* Metadata Row */}
<View style={[
styles.episodeMetadataRowHorizontal,
{
gap: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}>
{effectiveRuntime && (
<View style={styles.runtimeContainerHorizontal}>
<MaterialIcons name="schedule" size={isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14} color={currentTheme.colors.mediumEmphasis} />
<Text style={[
styles.runtimeTextHorizontal,
{
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11,
fontWeight: isTV ? '600' : isLargeTablet ? '500' : isTablet ? '500' : '500',
color: currentTheme.colors.mediumEmphasis
}
]}>
{formatRuntime(effectiveRuntime)}
</Text>
</View>
)}
{effectiveVote > 0 && (
<View style={styles.ratingContainerHorizontal}>
{isImdbRating ? (
<>
<Text style={[
styles.ratingTextHorizontal,
{
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11,
fontWeight: isTV ? '600' : isLargeTablet ? '600' : isTablet ? '600' : '600',
color: '#F5C518'
}
]}>
{effectiveVote.toFixed(1)}
</Text>
</>
) : (
<>
<FastImage
source={{ uri: TMDB_LOGO }}
style={[
styles.tmdbLogo,
{
width: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 20 : 20,
height: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14
}
]}
resizeMode={FastImage.resizeMode.contain}
/>
<Text style={[
styles.ratingTextHorizontal,
{
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11,
fontWeight: isTV ? '600' : isLargeTablet ? '600' : isTablet ? '600' : '600'
}
]}>
{effectiveVote.toFixed(1)}
</Text>
</>
)}
</View>
)}
{episode.air_date && (
<Text style={[
styles.airDateTextHorizontal,
{
color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11
}
]}>
{formatDate(episode.air_date)}
</Text>
)}
</View>
</View>
{/* Progress Bar */}
{showProgress && (
<View style={styles.progressBarContainerHorizontal}>
<View
style={[
styles.progressBarHorizontal,
{
width: `${progressPercent}%`,
backgroundColor: currentTheme.colors.primary,
}
]}
/>
</View>
)}
{/* Completed Badge */}
{progressPercent >= 85 && (
<View style={[
styles.completedBadgeHorizontal,
{
backgroundColor: currentTheme.colors.primary,
width: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 24 : 24,
height: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 24 : 24,
borderRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12,
top: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12,
left: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}>
<MaterialIcons name="check" size={isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16} color="#fff" />
</View>
)}
{(!progress || progressPercent === 0) && (
<View style={{
position: 'absolute',
top: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12,
left: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12,
width: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 24 : 24,
height: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 24 : 24,
borderRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12,
borderWidth: 2,
borderStyle: 'dashed',
borderColor: currentTheme.colors.textMuted,
opacity: 0.9,
}} />
)}
</LinearGradient>
</TouchableOpacity>
);
};
const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || [];
return (
<View style={styles.container}>
<Animated.View
entering={FadeIn.duration(300).delay(50)}
>
{renderSeasonSelector()}
</Animated.View>
<Animated.View
entering={FadeIn.duration(300).delay(100)}
>
<Text style={[
styles.sectionTitle,
{
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20,
marginBottom: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
paddingHorizontal: horizontalPadding
}
]}>
{currentSeasonEpisodes.length === 1 ? t('metadata.episode_count', { count: currentSeasonEpisodes.length }) : t('metadata.episode_count_plural', { count: currentSeasonEpisodes.length })}
</Text>
{/* Show message when no episodes are available for selected season */}
{currentSeasonEpisodes.length === 0 && (
<View style={styles.centeredContainer}>
<MaterialIcons name="schedule" size={48} color={currentTheme.colors.textMuted} />
<Text style={[styles.centeredText, { color: currentTheme.colors.text }]}>
{t('metadata.no_episodes_for_season', { season: selectedSeason })}
</Text>
<Text style={[styles.centeredSubText, { color: currentTheme.colors.textMuted }]}>
{t('metadata.episodes_not_released')}
</Text>
</View>
)}
{/* Only render episode list if there are episodes */}
{currentSeasonEpisodes.length > 0 && (
(settings?.episodeLayoutStyle === 'horizontal') ? (
// Horizontal Layout (Netflix-style) - Using FlatList
<FlatList
key={`episodes-${settings?.episodeLayoutStyle}-${selectedSeason}`}
ref={horizontalEpisodeScrollViewRef}
data={currentSeasonEpisodes}
renderItem={({ item: episode, index }) => (
<Animated.View
entering={enableItemAnimations ? FadeIn.duration(300).delay(100 + index * 30) : undefined as any}
style={[
styles.episodeCardWrapperHorizontal,
{
width: horizontalCardWidth,
marginRight: horizontalItemSpacing
}
]}
>
{renderHorizontalEpisodeCard(episode)}
</Animated.View>
)}
keyExtractor={episode => episode.id.toString()}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={[
styles.episodeListContentHorizontal,
{
paddingLeft: horizontalPadding,
paddingRight: horizontalPadding
}
]}
removeClippedSubviews
initialNumToRender={3}
maxToRenderPerBatch={5}
windowSize={5}
snapToInterval={horizontalCardWidth + horizontalItemSpacing}
snapToAlignment="start"
decelerationRate="fast"
getItemLayout={(data, index) => {
const length = horizontalCardWidth + horizontalItemSpacing;
return {
length,
offset: horizontalPadding + (length * index), // Account for left padding
index,
};
}}
onScrollToIndexFailed={(info) => {
// Fallback if scrollToIndex fails - use scrollToOffset with calculated position
const wait = new Promise(resolve => setTimeout(resolve, 500));
wait.then(() => {
if (horizontalEpisodeScrollViewRef.current) {
const length = horizontalCardWidth + horizontalItemSpacing;
const offset = horizontalPadding + (length * info.index);
horizontalEpisodeScrollViewRef.current.scrollToOffset({
offset: offset,
animated: true
});
}
});
}}
/>
) : (
// Vertical Layout (Traditional) - Using FlashList
<FlashList
key={`episodes-${settings?.episodeLayoutStyle}-${selectedSeason}`}
ref={episodeScrollViewRef}
data={currentSeasonEpisodes}
renderItem={({ item: episode, index }) => (
<Animated.View
entering={enableItemAnimations ? FadeIn.duration(300).delay(100 + index * 30) : undefined as any}
>
{renderVerticalEpisodeCard(episode)}
</Animated.View>
)}
keyExtractor={episode => episode.id.toString()}
contentContainerStyle={[
styles.episodeListContentVertical,
{
paddingHorizontal: horizontalPadding,
paddingBottom: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 24 : 8
}
]}
removeClippedSubviews
/>
)
)}
</Animated.View>
{/* Episode Action Menu Modal */}
<Modal
visible={episodeActionMenuVisible}
transparent
animationType="fade"
onRequestClose={closeEpisodeActionMenu}
statusBarTranslucent
supportedOrientations={['portrait', 'landscape']}
>
<Pressable
style={{
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.85)', // Darker overlay
justifyContent: 'center',
alignItems: 'center',
padding: 20,
}}
onPress={closeEpisodeActionMenu}
>
<Pressable
style={{
backgroundColor: '#1E1E1E', // Solid opaque dark background
borderRadius: isTV ? 20 : 16,
padding: isTV ? 24 : 20,
width: isTV ? 400 : isLargeTablet ? 360 : isTablet ? 320 : '100%',
maxWidth: 400,
alignSelf: 'center',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)', // Subtle border
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 10,
},
shadowOpacity: 0.51,
shadowRadius: 13.16,
elevation: 20,
}}
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
<View style={{ marginBottom: isTV ? 20 : 16 }}>
<Text style={{
color: '#FFFFFF', // High contrast text
fontSize: isTV ? 20 : 18,
fontWeight: '700',
marginBottom: 4,
}}>
{selectedEpisodeForAction ? `S${selectedEpisodeForAction.season_number}E${selectedEpisodeForAction.episode_number}` : ''}
</Text>
<Text style={{
color: '#AAAAAA', // Medium emphasis text
fontSize: isTV ? 16 : 14,
}} numberOfLines={1} ellipsizeMode="tail">
{selectedEpisodeForAction?.name || ''}
</Text>
</View>
{/* Action buttons */}
<View style={{ gap: isTV ? 12 : 10 }}>
{/* Mark as Watched / Unwatched */}
{selectedEpisodeForAction && (
isEpisodeWatched(selectedEpisodeForAction) ? (
<TouchableOpacity
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.08)', // Defined background
padding: isTV ? 16 : 14,
borderRadius: isTV ? 12 : 10,
opacity: markingAsWatched ? 0.5 : 1,
}}
onPress={handleMarkAsUnwatched}
disabled={markingAsWatched}
>
<MaterialIcons
name="visibility-off"
size={isTV ? 24 : 22}
color="#FFFFFF"
style={{ marginRight: 12 }}
/>
<Text style={{
color: '#FFFFFF',
fontSize: isTV ? 16 : 15,
fontWeight: '500',
}}>
{markingAsWatched ? t('metadata.removing') : t('metadata.mark_as_unwatched')}
</Text>
</TouchableOpacity>
) : (
<TouchableOpacity
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: currentTheme.colors.primary,
padding: isTV ? 16 : 14,
borderRadius: isTV ? 12 : 10,
opacity: markingAsWatched ? 0.5 : 1,
}}
onPress={handleMarkAsWatched}
disabled={markingAsWatched}
>
<MaterialIcons
name="check-circle"
size={isTV ? 24 : 22}
color="#FFFFFF"
style={{ marginRight: 12 }}
/>
<Text style={{
color: '#FFFFFF',
fontSize: isTV ? 16 : 15,
fontWeight: '600',
}}>
{markingAsWatched ? t('metadata.marking') : t('metadata.mark_as_watched')}
</Text>
</TouchableOpacity>
)
)}
{/* Mark Season as Watched / Unwatched */}
{isSeasonWatched() ? (
<TouchableOpacity
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.08)',
padding: isTV ? 16 : 14,
borderRadius: isTV ? 12 : 10,
opacity: markingAsWatched ? 0.5 : 1,
}}
onPress={handleMarkSeasonAsUnwatched}
disabled={markingAsWatched}
>
<MaterialIcons
name="playlist-remove"
size={isTV ? 24 : 22}
color="#FFFFFF"
style={{ marginRight: 12 }}
/>
<Text style={{
color: '#FFFFFF',
fontSize: isTV ? 16 : 15,
fontWeight: '500',
flex: 1, // Allow text to take up space
}} numberOfLines={1}>
{markingAsWatched ? t('metadata.removing') : t('metadata.unmark_season', { season: selectedSeason })}
</Text>
</TouchableOpacity>
) : (
<TouchableOpacity
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.08)',
padding: isTV ? 16 : 14,
borderRadius: isTV ? 12 : 10,
opacity: markingAsWatched ? 0.5 : 1,
}}
onPress={handleMarkSeasonAsWatched}
disabled={markingAsWatched}
>
<MaterialIcons
name="playlist-add-check"
size={isTV ? 24 : 22}
color="#FFFFFF"
style={{ marginRight: 12 }}
/>
<Text style={{
color: '#FFFFFF',
fontSize: isTV ? 16 : 15,
fontWeight: '500',
flex: 1,
}} numberOfLines={1}>
{markingAsWatched ? t('metadata.marking') : t('metadata.mark_season', { season: selectedSeason })}
</Text>
</TouchableOpacity>
)}
{/* Cancel */}
<TouchableOpacity
style={{
alignItems: 'center',
padding: isTV ? 14 : 12,
marginTop: isTV ? 8 : 4,
}}
onPress={closeEpisodeActionMenu}
>
<Text style={{
color: '#999999',
fontSize: isTV ? 15 : 14,
fontWeight: '500',
}}>
{t('common.cancel')}
</Text>
</TouchableOpacity>
</View>
</Pressable>
</Pressable>
</Modal>
</View>
);
};
// Export memoized component to reduce unnecessary re-renders when focused
export const SeriesContent = memo(SeriesContentComponent);
const styles = StyleSheet.create({
container: {
flex: 1,
paddingVertical: 16,
},
centeredContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
centeredText: {
marginTop: 12,
fontSize: 16,
textAlign: 'center',
},
centeredSubText: {
marginTop: 8,
fontSize: 14,
textAlign: 'center',
opacity: 0.8,
},
sectionTitle: {
fontSize: 20,
fontWeight: '700',
marginBottom: 16,
paddingHorizontal: 16,
},
episodeList: {
flex: 1,
},
// Vertical Layout Styles
episodeListContentVertical: {
paddingBottom: 8,
},
episodeGridVertical: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
gap: 16,
},
episodeCardVertical: {
flexDirection: 'row',
borderRadius: 16,
marginBottom: 16,
overflow: 'hidden',
elevation: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.25,
shadowRadius: 8,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
height: 120,
},
episodeCardVerticalTablet: {
width: '100%',
flexDirection: 'row',
height: 160,
marginBottom: 16,
},
episodeImageContainer: {
position: 'relative',
width: 120,
height: 120,
},
episodeImageContainerTablet: {
width: 160,
height: 160,
},
episodeImage: {
width: '100%',
height: '100%',
transform: [{ scale: 1.02 }],
},
episodeNumberBadge: {
position: 'absolute',
bottom: 8,
right: 4,
backgroundColor: 'rgba(0,0,0,0.85)',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.2)',
zIndex: 1,
},
episodeNumberText: {
color: '#fff',
fontSize: 11,
fontWeight: '600',
letterSpacing: 0.3,
},
episodeInfo: {
flex: 1,
padding: 12,
justifyContent: 'center',
},
episodeInfoTablet: {
padding: 16,
},
episodeHeader: {
marginBottom: 4,
},
episodeHeaderTablet: {
marginBottom: 6,
},
episodeTitle: {
fontSize: 15,
fontWeight: '700',
letterSpacing: 0.3,
marginBottom: 2,
},
episodeTitleTablet: {
fontSize: 16,
marginBottom: 4,
},
episodeMetadata: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
episodeMetadataTablet: {
gap: 6,
flexWrap: 'wrap',
},
ratingContainer: {
flexDirection: 'row',
alignItems: 'center',
// chip background removed
},
tmdbLogo: {
width: 20,
height: 14,
},
ratingText: {
color: '#01b4e4',
fontSize: 13,
fontWeight: '700',
marginLeft: 4,
},
runtimeContainer: {
flexDirection: 'row',
alignItems: 'center',
// chip background removed
minWidth: 52, // reserve space so following items (rating) don't shift
},
runtimeText: {
fontSize: 13,
fontWeight: '600',
marginLeft: 4,
},
airDateText: {
fontSize: 12,
opacity: 0.8,
},
episodeOverview: {
fontSize: 13,
lineHeight: 18,
},
episodeOverviewTablet: {
fontSize: 14,
lineHeight: 20,
},
progressBarContainer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 3,
backgroundColor: 'rgba(0,0,0,0.5)',
},
progressBar: {
height: '100%',
},
completedBadge: {
position: 'absolute',
top: 8,
left: 8,
width: 20,
height: 20,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.3)',
zIndex: 2,
},
// Horizontal Layout Styles
episodeListContentHorizontal: {
// Padding will be added responsively
},
episodeCardWrapperHorizontal: {
// Dimensions will be set responsively
},
episodeCardHorizontal: {
borderRadius: 16,
overflow: 'hidden',
elevation: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.35,
shadowRadius: 12,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.05)',
height: 200,
position: 'relative',
width: '100%',
backgroundColor: 'transparent',
},
episodeBackgroundImage: {
width: '100%',
height: '100%',
borderRadius: 16,
},
episodeGradient: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: 16,
justifyContent: 'flex-end',
},
episodeContent: {
padding: 12,
paddingBottom: 16,
},
episodeContentTablet: {
padding: 16,
paddingBottom: 20,
},
episodeNumberBadgeHorizontal: {
backgroundColor: 'rgba(0,0,0,0.4)',
paddingHorizontal: 6,
paddingVertical: 3,
borderRadius: 4,
marginBottom: 6,
alignSelf: 'flex-start',
},
episodeNumberBadgeHorizontalTablet: {
backgroundColor: 'rgba(0,0,0,0.5)',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 6,
marginBottom: 8,
alignSelf: 'flex-start',
},
episodeNumberHorizontal: {
color: 'rgba(255,255,255,0.8)',
fontSize: 10,
fontWeight: '600',
letterSpacing: 0.8,
textTransform: 'uppercase',
marginBottom: 2,
},
episodeNumberHorizontalTablet: {
color: 'rgba(255,255,255,0.9)',
fontSize: 12,
fontWeight: '700',
letterSpacing: 1.0,
textTransform: 'uppercase',
marginBottom: 2,
},
episodeTitleHorizontal: {
color: '#fff',
fontSize: 15,
fontWeight: '700',
letterSpacing: -0.3,
marginBottom: 4,
lineHeight: 18,
},
episodeTitleHorizontalTablet: {
color: '#fff',
fontSize: 18,
fontWeight: '800',
letterSpacing: -0.4,
marginBottom: 6,
lineHeight: 22,
},
episodeDescriptionHorizontal: {
color: 'rgba(255,255,255,0.85)',
fontSize: 12,
lineHeight: 16,
marginBottom: 8,
opacity: 0.9,
},
episodeDescriptionHorizontalTablet: {
color: 'rgba(255,255,255,0.9)',
fontSize: 14,
lineHeight: 18,
marginBottom: 10,
opacity: 0.95,
},
episodeMetadataRowHorizontal: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
runtimeContainerHorizontal: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
// chip background removed
},
runtimeTextHorizontal: {
color: 'rgba(255,255,255,0.8)',
fontSize: 11,
fontWeight: '500',
},
airDateTextHorizontal: {
color: 'rgba(255,255,255,0.8)',
fontSize: 11,
opacity: 0.8,
},
ratingContainerHorizontal: {
flexDirection: 'row',
alignItems: 'center',
// chip background removed
gap: 2,
},
ratingTextHorizontal: {
color: '#FFD700',
fontSize: 11,
fontWeight: '600',
},
progressBarContainerHorizontal: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 3,
backgroundColor: 'rgba(255,255,255,0.2)',
},
progressBarHorizontal: {
height: '100%',
borderRadius: 2,
},
completedBadgeHorizontal: {
position: 'absolute',
top: 12,
left: 12,
width: 24,
height: 24,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: '#fff',
},
// Season Selector Styles
seasonSelectorWrapper: {
marginBottom: 20,
},
seasonSelectorHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
seasonSelectorTitle: {
fontSize: 18,
fontWeight: '600',
marginBottom: 0, // Removed margin bottom here
},
seasonSelectorTitleTablet: {
fontSize: 22,
fontWeight: '700',
marginBottom: 0, // Removed margin bottom here
},
seasonSelectorContainer: {
flexGrow: 0,
},
seasonSelectorContent: {
paddingBottom: 8,
},
seasonSelectorContentTablet: {
paddingBottom: 12,
},
seasonButton: {
alignItems: 'center',
},
selectedSeasonButton: {
opacity: 1,
},
seasonPosterContainer: {
position: 'relative',
borderRadius: 8,
overflow: 'hidden',
},
seasonPoster: {
width: '100%',
height: '100%',
},
selectedSeasonIndicator: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 4,
},
selectedSeasonIndicatorTablet: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 6,
},
seasonButtonText: {
fontSize: 14,
fontWeight: '500',
},
seasonButtonTextTablet: {
fontSize: 16,
fontWeight: '600',
},
selectedSeasonButtonText: {
fontWeight: '700',
},
selectedSeasonButtonTextTablet: {
fontWeight: '800',
},
seasonViewToggle: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 6,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.2)',
},
seasonViewToggleText: {
fontSize: 12,
fontWeight: '500',
marginRight: 4,
},
seasonTextButton: {
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
},
selectedSeasonTextButton: {
backgroundColor: 'rgba(255,255,255,0.08)',
},
seasonTextButtonText: {
fontSize: 15,
fontWeight: '600',
letterSpacing: 0.3,
textAlign: 'center',
},
seasonTextButtonTextTablet: {
fontSize: 17,
fontWeight: '600',
letterSpacing: 0.4,
textAlign: 'center',
},
selectedSeasonTextButtonText: {
fontWeight: '700',
},
selectedSeasonTextButtonTextTablet: {
fontWeight: '800',
},
});