mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-14 15:06:06 +00:00
1416 lines
No EOL
45 KiB
TypeScript
1416 lines
No EOL
45 KiB
TypeScript
import React, { useEffect, useState, useRef } from 'react';
|
|
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme, FlatList } from 'react-native';
|
|
import { Image } from 'expo-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 } 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 { logger } from '../../utils/logger';
|
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
|
|
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 };
|
|
}
|
|
|
|
// 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';
|
|
|
|
export const SeriesContent: React.FC<SeriesContentProps> = ({
|
|
episodes,
|
|
selectedSeason,
|
|
loadingSeasons,
|
|
onSeasonChange,
|
|
onSelectEpisode,
|
|
groupedEpisodes = {},
|
|
metadata
|
|
}) => {
|
|
const { currentTheme } = useTheme();
|
|
const { settings } = useSettings();
|
|
const { width } = useWindowDimensions();
|
|
const isTablet = width > 768;
|
|
const isDarkMode = useColorScheme() === 'dark';
|
|
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 } }>({});
|
|
|
|
// 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);
|
|
|
|
// 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 view mode preference when component mounts or show changes
|
|
useEffect(() => {
|
|
const loadViewModePreference = async () => {
|
|
if (metadata?.id) {
|
|
try {
|
|
const savedMode = await AsyncStorage.getItem(`season_view_mode_${metadata.id}`);
|
|
if (savedMode === 'text' || savedMode === 'posters') {
|
|
setSeasonViewMode(savedMode);
|
|
if (__DEV__) console.log('[SeriesContent] Loaded saved view mode:', savedMode, 'for show:', metadata.id);
|
|
}
|
|
} catch (error) {
|
|
if (__DEV__) console.log('[SeriesContent] Error loading view mode preference:', error);
|
|
}
|
|
}
|
|
};
|
|
|
|
loadViewModePreference();
|
|
}, [metadata?.id]);
|
|
|
|
// 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);
|
|
if (metadata?.id) {
|
|
AsyncStorage.setItem(`season_view_mode_${metadata.id}`, newMode).catch(error => {
|
|
if (__DEV__) console.log('[SeriesContent] Error saving 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) {
|
|
const historyItems = await traktService.getWatchedEpisodesHistory(1, 400);
|
|
|
|
historyItems.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) {
|
|
const cardWidth = isTablet ? width * 0.4 + 16 : width * 0.85 + 16;
|
|
const scrollPosition = mostRecentEpisodeIndex * cardWidth;
|
|
|
|
setTimeout(() => {
|
|
if (horizontalEpisodeScrollViewRef.current) {
|
|
horizontalEpisodeScrollViewRef.current.scrollToOffset({
|
|
offset: scrollPosition,
|
|
animated: true
|
|
});
|
|
}
|
|
}, 500); // Delay to ensure the season has loaded
|
|
}
|
|
};
|
|
|
|
// Initial load of watch progress
|
|
useEffect(() => {
|
|
loadEpisodesProgress();
|
|
}, [episodes, metadata?.id]);
|
|
|
|
// Hydrate TMDB rating/runtime for current season episodes if missing
|
|
useEffect(() => {
|
|
const hydrateFromTmdb = async () => {
|
|
try {
|
|
if (!metadata?.id || !selectedSeason) 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]);
|
|
|
|
// 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])
|
|
);
|
|
|
|
// Add effect to scroll to selected season
|
|
useEffect(() => {
|
|
if (selectedSeason && seasonScrollViewRef.current && Object.keys(groupedEpisodes).length > 0) {
|
|
// 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]);
|
|
|
|
|
|
|
|
if (loadingSeasons) {
|
|
return (
|
|
<View style={styles.centeredContainer}>
|
|
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
|
|
<Text style={[styles.centeredText, { color: currentTheme.colors.text }]}>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 }]}>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;
|
|
}
|
|
|
|
if (__DEV__) console.log('[SeriesContent] renderSeasonSelector called, current view mode:', seasonViewMode);
|
|
|
|
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b);
|
|
|
|
return (
|
|
<View style={[styles.seasonSelectorWrapper, isTablet && styles.seasonSelectorWrapperTablet]}>
|
|
<View style={styles.seasonSelectorHeader}>
|
|
<Text style={[
|
|
styles.seasonSelectorTitle,
|
|
isTablet && styles.seasonSelectorTitleTablet,
|
|
{ color: currentTheme.colors.highEmphasis }
|
|
]}>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)'
|
|
}
|
|
]}
|
|
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
|
|
}
|
|
]}>
|
|
{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, isTablet && styles.seasonSelectorContentTablet]}
|
|
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, 'w500');
|
|
if (tmdbUrl) seasonPoster = tmdbUrl;
|
|
} else if (metadata?.poster) {
|
|
seasonPoster = metadata.poster;
|
|
}
|
|
|
|
if (seasonViewMode === 'text') {
|
|
// Text-only view
|
|
if (__DEV__) console.log('[SeriesContent] Rendering text view for season:', season, 'View mode ref:', seasonViewMode);
|
|
return (
|
|
<View
|
|
key={season}
|
|
style={{ opacity: textViewVisible ? 1 : 0 }}
|
|
>
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.seasonTextButton,
|
|
isTablet && styles.seasonTextButtonTablet,
|
|
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 {season}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// Poster view (current implementation)
|
|
if (__DEV__) console.log('[SeriesContent] Rendering poster view for season:', season, 'View mode ref:', seasonViewMode);
|
|
return (
|
|
<View
|
|
key={season}
|
|
style={{ opacity: posterViewVisible ? 1 : 0 }}
|
|
>
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.seasonButton,
|
|
isTablet && styles.seasonButtonTablet,
|
|
selectedSeason === season && [styles.selectedSeasonButton, { borderColor: currentTheme.colors.primary }]
|
|
]}
|
|
onPress={() => onSeasonChange(season)}
|
|
>
|
|
<View style={[styles.seasonPosterContainer, isTablet && styles.seasonPosterContainerTablet]}>
|
|
<Image
|
|
source={{ uri: seasonPoster }}
|
|
style={styles.seasonPoster}
|
|
contentFit="cover"
|
|
/>
|
|
{selectedSeason === season && (
|
|
<View style={[
|
|
styles.selectedSeasonIndicator,
|
|
isTablet && styles.selectedSeasonIndicatorTablet,
|
|
{ backgroundColor: currentTheme.colors.primary }
|
|
]} />
|
|
)}
|
|
|
|
</View>
|
|
<Text
|
|
style={[
|
|
styles.seasonButtonText,
|
|
isTablet && styles.seasonButtonTextTablet,
|
|
{ color: currentTheme.colors.mediumEmphasis },
|
|
selectedSeason === season && [
|
|
styles.selectedSeasonButtonText,
|
|
isTablet && styles.selectedSeasonButtonTextTablet,
|
|
{ color: currentTheme.colors.primary }
|
|
]
|
|
]}
|
|
>
|
|
Season {season}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
}}
|
|
keyExtractor={season => season.toString()}
|
|
/>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
// Vertical layout episode card (traditional)
|
|
const renderVerticalEpisodeCard = (episode: Episode) => {
|
|
let episodeImage = EPISODE_PLACEHOLDER;
|
|
if (episode.still_path) {
|
|
// Check if still_path is already a full URL
|
|
if (episode.still_path.startsWith('http')) {
|
|
episodeImage = episode.still_path;
|
|
} else {
|
|
const tmdbUrl = tmdbService.getImageUrl(episode.still_path, 'w500');
|
|
if (tmdbUrl) episodeImage = tmdbUrl;
|
|
}
|
|
} else if (metadata?.poster) {
|
|
episodeImage = metadata.poster;
|
|
}
|
|
|
|
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}`];
|
|
const effectiveVote = (tmdbOverride?.vote_average ?? episode.vote_average) || 0;
|
|
const effectiveRuntime = tmdbOverride?.runtime ?? (episode as any).runtime;
|
|
if (!episode.still_path && tmdbOverride?.still_path) {
|
|
const tmdbUrl = tmdbService.getImageUrl(tmdbOverride.still_path, 'w500');
|
|
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 }
|
|
]}
|
|
onPress={() => onSelectEpisode(episode)}
|
|
activeOpacity={0.7}
|
|
>
|
|
<View style={[
|
|
styles.episodeImageContainer,
|
|
isTablet && styles.episodeImageContainerTablet
|
|
]}>
|
|
<Image
|
|
source={{ uri: episodeImage }}
|
|
style={styles.episodeImage}
|
|
contentFit="cover"
|
|
/>
|
|
<View style={styles.episodeNumberBadge}>
|
|
<Text style={styles.episodeNumberText}>{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 }]}>
|
|
<MaterialIcons name="check" size={12} color={currentTheme.colors.white} />
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
<View style={[
|
|
styles.episodeInfo,
|
|
isTablet && styles.episodeInfoTablet
|
|
]}>
|
|
<View style={[
|
|
styles.episodeHeader,
|
|
isTablet && styles.episodeHeaderTablet
|
|
]}>
|
|
<Text style={[
|
|
styles.episodeTitle,
|
|
isTablet && styles.episodeTitleTablet,
|
|
{ color: currentTheme.colors.text }
|
|
]} numberOfLines={2}>
|
|
{episode.name}
|
|
</Text>
|
|
<View style={[
|
|
styles.episodeMetadata,
|
|
isTablet && styles.episodeMetadataTablet
|
|
]}>
|
|
{effectiveVote > 0 && (
|
|
<View style={styles.ratingContainer}>
|
|
<Image
|
|
source={{ uri: TMDB_LOGO }}
|
|
style={styles.tmdbLogo}
|
|
contentFit="contain"
|
|
/>
|
|
<Text style={[styles.ratingText, { color: currentTheme.colors.textMuted }]}>
|
|
{effectiveVote.toFixed(1)}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
{effectiveRuntime && (
|
|
<View style={styles.runtimeContainer}>
|
|
<MaterialIcons name="schedule" size={14} color={currentTheme.colors.textMuted} />
|
|
<Text style={[styles.runtimeText, { color: currentTheme.colors.textMuted }]}>
|
|
{formatRuntime(effectiveRuntime)}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
{episode.air_date && (
|
|
<Text style={[styles.airDateText, { color: currentTheme.colors.textMuted }]}>
|
|
{formatDate(episode.air_date)}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
</View>
|
|
<Text style={[
|
|
styles.episodeOverview,
|
|
isTablet && styles.episodeOverviewTablet,
|
|
{ color: currentTheme.colors.mediumEmphasis }
|
|
]} numberOfLines={isTablet ? 3 : 2}>
|
|
{episode.overview || 'No description available'}
|
|
</Text>
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
};
|
|
|
|
// Horizontal layout episode card (Netflix-style)
|
|
const renderHorizontalEpisodeCard = (episode: Episode) => {
|
|
let episodeImage = EPISODE_PLACEHOLDER;
|
|
if (episode.still_path) {
|
|
// Check if still_path is already a full URL
|
|
if (episode.still_path.startsWith('http')) {
|
|
episodeImage = episode.still_path;
|
|
} else {
|
|
const tmdbUrl = tmdbService.getImageUrl(episode.still_path, 'w500');
|
|
if (tmdbUrl) episodeImage = tmdbUrl;
|
|
}
|
|
} else if (metadata?.poster) {
|
|
episodeImage = metadata.poster;
|
|
}
|
|
|
|
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 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,
|
|
isTablet && styles.episodeCardHorizontalTablet,
|
|
// Gradient border styling
|
|
{
|
|
borderWidth: 1,
|
|
borderColor: 'transparent',
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 4 },
|
|
shadowOpacity: 0.3,
|
|
shadowRadius: 8,
|
|
elevation: 12,
|
|
}
|
|
]}
|
|
onPress={() => onSelectEpisode(episode)}
|
|
activeOpacity={0.85}
|
|
>
|
|
{/* Gradient Border Container */}
|
|
<View style={{
|
|
position: 'absolute',
|
|
top: -1,
|
|
left: -1,
|
|
right: -1,
|
|
bottom: -1,
|
|
borderRadius: 17,
|
|
zIndex: -1,
|
|
}}>
|
|
<LinearGradient
|
|
colors={[
|
|
'#ffffff80', // White with 50% opacity
|
|
'#ffffff40', // White with 25% opacity
|
|
'#ffffff20', // White with 12% opacity
|
|
'#ffffff40', // White with 25% opacity
|
|
'#ffffff80', // White with 50% opacity
|
|
]}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 1 }}
|
|
style={{
|
|
flex: 1,
|
|
borderRadius: 17,
|
|
}}
|
|
/>
|
|
</View>
|
|
|
|
{/* Background Image */}
|
|
<Image
|
|
source={{ uri: episodeImage }}
|
|
style={styles.episodeBackgroundImage}
|
|
contentFit="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, isTablet && styles.episodeContentTablet]}>
|
|
{/* Episode Number Badge */}
|
|
<View style={[styles.episodeNumberBadgeHorizontal, isTablet && styles.episodeNumberBadgeHorizontalTablet]}>
|
|
<Text style={[styles.episodeNumberHorizontal, isTablet && styles.episodeNumberHorizontalTablet]}>{episodeString}</Text>
|
|
</View>
|
|
|
|
{/* Episode Title */}
|
|
<Text style={[styles.episodeTitleHorizontal, isTablet && styles.episodeTitleHorizontalTablet]} numberOfLines={2}>
|
|
{episode.name}
|
|
</Text>
|
|
|
|
{/* Episode Description */}
|
|
<Text style={[styles.episodeDescriptionHorizontal, isTablet && styles.episodeDescriptionHorizontalTablet]} numberOfLines={3}>
|
|
{episode.overview || 'No description available'}
|
|
</Text>
|
|
|
|
{/* Metadata Row */}
|
|
<View style={styles.episodeMetadataRowHorizontal}>
|
|
{episode.runtime && (
|
|
<View style={styles.runtimeContainerHorizontal}>
|
|
<Text style={styles.runtimeTextHorizontal}>
|
|
{formatRuntime(episode.runtime)}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
{episode.vote_average > 0 && (
|
|
<View style={styles.ratingContainerHorizontal}>
|
|
<MaterialIcons name="star" size={14} color="#FFD700" />
|
|
<Text style={styles.ratingTextHorizontal}>
|
|
{episode.vote_average.toFixed(1)}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</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,
|
|
}]}>
|
|
<MaterialIcons name="check" size={16} color="#fff" />
|
|
</View>
|
|
)}
|
|
|
|
</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 }]}>
|
|
{currentSeasonEpisodes.length} {currentSeasonEpisodes.length === 1 ? 'Episode' : 'Episodes'}
|
|
</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 }]}>
|
|
No episodes available for Season {selectedSeason}
|
|
</Text>
|
|
<Text style={[styles.centeredSubText, { color: currentTheme.colors.textMuted }]}>
|
|
Episodes may not be released yet
|
|
</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,
|
|
isTablet && styles.episodeCardWrapperHorizontalTablet
|
|
]}
|
|
>
|
|
{renderHorizontalEpisodeCard(episode)}
|
|
</Animated.View>
|
|
)}
|
|
keyExtractor={episode => episode.id.toString()}
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={isTablet ? styles.episodeListContentHorizontalTablet : styles.episodeListContentHorizontal}
|
|
removeClippedSubviews
|
|
initialNumToRender={3}
|
|
maxToRenderPerBatch={5}
|
|
windowSize={5}
|
|
getItemLayout={(data, index) => {
|
|
const cardWidth = isTablet ? width * 0.4 : width * 0.75;
|
|
const margin = isTablet ? 20 : 16;
|
|
return {
|
|
length: cardWidth + margin,
|
|
offset: (cardWidth + margin) * index,
|
|
index,
|
|
};
|
|
}}
|
|
/>
|
|
) : (
|
|
// 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={isTablet ? styles.episodeListContentVerticalTablet : styles.episodeListContentVertical}
|
|
removeClippedSubviews
|
|
/>
|
|
)
|
|
)}
|
|
</Animated.View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
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: 20,
|
|
paddingHorizontal: 16,
|
|
},
|
|
episodeListContentVerticalTablet: {
|
|
paddingHorizontal: 16,
|
|
paddingBottom: 20,
|
|
},
|
|
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
|
|
},
|
|
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: {
|
|
paddingLeft: 16,
|
|
paddingRight: 16,
|
|
},
|
|
episodeListContentHorizontalTablet: {
|
|
paddingLeft: 24,
|
|
paddingRight: 24,
|
|
},
|
|
episodeCardWrapperHorizontal: {
|
|
width: Dimensions.get('window').width * 0.75,
|
|
marginRight: 16,
|
|
},
|
|
episodeCardWrapperHorizontalTablet: {
|
|
width: Dimensions.get('window').width * 0.4,
|
|
marginRight: 20,
|
|
},
|
|
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',
|
|
},
|
|
episodeCardHorizontalTablet: {
|
|
height: 260,
|
|
borderRadius: 20,
|
|
elevation: 12,
|
|
shadowOpacity: 0.4,
|
|
shadowRadius: 16,
|
|
},
|
|
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',
|
|
// chip background removed
|
|
},
|
|
runtimeTextHorizontal: {
|
|
color: 'rgba(255,255,255,0.8)',
|
|
fontSize: 11,
|
|
fontWeight: '500',
|
|
},
|
|
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,
|
|
paddingHorizontal: 16,
|
|
},
|
|
seasonSelectorWrapperTablet: {
|
|
marginBottom: 24,
|
|
paddingHorizontal: 24,
|
|
},
|
|
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',
|
|
marginRight: 16,
|
|
width: 100,
|
|
},
|
|
seasonButtonTablet: {
|
|
alignItems: 'center',
|
|
marginRight: 20,
|
|
width: 120,
|
|
},
|
|
selectedSeasonButton: {
|
|
opacity: 1,
|
|
},
|
|
seasonPosterContainer: {
|
|
position: 'relative',
|
|
width: 100,
|
|
height: 150,
|
|
borderRadius: 8,
|
|
overflow: 'hidden',
|
|
marginBottom: 8,
|
|
},
|
|
seasonPosterContainerTablet: {
|
|
position: 'relative',
|
|
width: 120,
|
|
height: 180,
|
|
borderRadius: 12,
|
|
overflow: 'hidden',
|
|
marginBottom: 12,
|
|
},
|
|
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',
|
|
marginRight: 16,
|
|
width: 110,
|
|
justifyContent: 'center',
|
|
paddingVertical: 12,
|
|
paddingHorizontal: 16,
|
|
borderRadius: 12,
|
|
backgroundColor: 'transparent',
|
|
},
|
|
seasonTextButtonTablet: {
|
|
alignItems: 'center',
|
|
marginRight: 20,
|
|
width: 130,
|
|
justifyContent: 'center',
|
|
paddingVertical: 14,
|
|
paddingHorizontal: 18,
|
|
borderRadius: 14,
|
|
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',
|
|
},
|
|
|
|
}); |