diff --git a/src/components/metadata/MetadataDetails.tsx b/src/components/metadata/MetadataDetails.tsx index bc52276f..035baaf8 100644 --- a/src/components/metadata/MetadataDetails.tsx +++ b/src/components/metadata/MetadataDetails.tsx @@ -12,7 +12,7 @@ import Animated, { FadeIn, } from 'react-native-reanimated'; import { useTheme } from '../../contexts/ThemeContext'; -import MetadataSourceSelector from './MetadataSourceSelector'; +// MetadataSourceSelector removed interface MetadataDetailsProps { metadata: any; @@ -20,8 +20,7 @@ interface MetadataDetailsProps { type: 'movie' | 'series'; renderRatings?: () => React.ReactNode; contentId: string; - currentMetadataSource?: string; - onMetadataSourceChange?: (sourceId: string, sourceType: 'addon' | 'tmdb') => void; + // Source switching removed loadingMetadata?: boolean; } @@ -31,8 +30,6 @@ const MetadataDetails: React.FC = ({ type, renderRatings, contentId, - currentMetadataSource, - onMetadataSourceChange, loadingMetadata = false, }) => { const { currentTheme } = useTheme(); @@ -40,23 +37,7 @@ const MetadataDetails: React.FC = ({ return ( <> - {/* Metadata Source Selector */} - {onMetadataSourceChange && ( - - - {currentMetadataSource && currentMetadataSource !== 'auto' && ( - - Currently showing metadata from: {currentMetadataSource === 'tmdb' ? 'TMDB' : currentMetadataSource} - - )} - - )} + {/* Metadata Source Selector removed */} {/* Loading indicator when switching sources */} {loadingMetadata && ( @@ -144,15 +125,7 @@ const MetadataDetails: React.FC = ({ }; const styles = StyleSheet.create({ - sourceSelectorContainer: { - paddingHorizontal: 16, - marginBottom: 16, - }, - sourceIndicator: { - fontSize: 12, - marginTop: 4, - fontStyle: 'italic', - }, + // Removed source selector styles loadingContainer: { flexDirection: 'row', alignItems: 'center', diff --git a/src/components/metadata/MetadataSourceSelector.tsx b/src/components/metadata/MetadataSourceSelector.tsx deleted file mode 100644 index c34f516c..00000000 --- a/src/components/metadata/MetadataSourceSelector.tsx +++ /dev/null @@ -1,523 +0,0 @@ -import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -import { - View, - Text, - StyleSheet, - TouchableOpacity, - Modal, - ScrollView, - ActivityIndicator, - Animated, -} from 'react-native'; -import { MaterialIcons } from '@expo/vector-icons'; -import * as Haptics from 'expo-haptics'; -import { useTheme } from '../../contexts/ThemeContext'; -import { stremioService } from '../../services/stremioService'; -import { tmdbService } from '../../services/tmdbService'; -import { catalogService } from '../../services/catalogService'; -import { logger } from '../../utils/logger'; - -interface MetadataSource { - id: string; - name: string; - type: 'addon' | 'tmdb'; - hasMetaSupport?: boolean; - icon?: string; -} - -interface MetadataSourceSelectorProps { - currentSource?: string; - contentId: string; - contentType: string; - onSourceChange: (sourceId: string, sourceType: 'addon' | 'tmdb') => void; - disabled?: boolean; -} - -const MetadataSourceSelector: React.FC = ({ - currentSource, - contentId, - contentType, - onSourceChange, - disabled = false, -}) => { - const { currentTheme } = useTheme(); - const [isVisible, setIsVisible] = useState(false); - const [sources, setSources] = useState([]); - const [loading, setLoading] = useState(false); - const [selectedSource, setSelectedSource] = useState(currentSource || 'auto'); - const scaleAnim = useRef(new Animated.Value(0.8)).current; - const opacityAnim = useRef(new Animated.Value(0)).current; - - // Load available metadata sources - const loadMetadataSources = useCallback(async () => { - setLoading(true); - try { - const sources: MetadataSource[] = []; - - // Add auto-select option - sources.push({ - id: 'auto', - name: 'Auto (Best Available)', - type: 'addon', - icon: 'auto-fix-high', - }); - - // Add TMDB as a source - sources.push({ - id: 'tmdb', - name: 'The Movie Database (TMDB)', - type: 'tmdb', - icon: 'movie', - }); - - // Get installed addons with meta support - const addons = await stremioService.getInstalledAddonsAsync(); - logger.log(`[MetadataSourceSelector] Checking ${addons.length} installed addons for meta support`); - - for (const addon of addons) { - logger.log(`[MetadataSourceSelector] Checking addon: ${addon.name} (${addon.id})`); - logger.log(`[MetadataSourceSelector] Addon resources:`, addon.resources); - - // Check if addon supports meta resource - let hasMetaSupport = false; - - if (addon.resources) { - // Handle both array of strings and array of objects - hasMetaSupport = addon.resources.some((resource: any) => { - if (typeof resource === 'string') { - // Simple string format like ["catalog", "meta"] - return resource === 'meta'; - } else if (resource && typeof resource === 'object') { - // Object format like { name: 'meta', types: ['movie', 'series'] } - const supportsType = !resource.types || resource.types.includes(contentType); - logger.log(`[MetadataSourceSelector] Resource ${resource.name}: types=${resource.types}, supportsType=${supportsType}`); - return resource.name === 'meta' && supportsType; - } - return false; - }); - } - - logger.log(`[MetadataSourceSelector] Addon ${addon.name} has meta support: ${hasMetaSupport}`); - - if (hasMetaSupport) { - sources.push({ - id: addon.id, - name: addon.name, - type: 'addon', - hasMetaSupport: true, - icon: 'extension', - }); - logger.log(`[MetadataSourceSelector] Added addon ${addon.name} as metadata source`); - } - } - - // Sort sources: auto first, then TMDB, then addons alphabetically - sources.sort((a, b) => { - if (a.id === 'auto') return -1; - if (b.id === 'auto') return 1; - if (a.id === 'tmdb') return -1; - if (b.id === 'tmdb') return 1; - return a.name.localeCompare(b.name); - }); - - // Always include at least auto and TMDB for comparison - if (sources.length < 2) { - logger.warn('[MetadataSourceSelector] Less than 2 sources found, this should not happen'); - } - - setSources(sources); - logger.log(`[MetadataSourceSelector] Found ${sources.length} metadata sources:`, sources.map(s => s.name)); - } catch (error) { - logger.error('[MetadataSourceSelector] Failed to load metadata sources:', error); - } finally { - setLoading(false); - } - }, [contentType]); - - // Load sources on mount - useEffect(() => { - loadMetadataSources(); - }, [loadMetadataSources]); - - // Update selected source when currentSource prop changes - useEffect(() => { - if (currentSource) { - setSelectedSource(currentSource); - } - }, [currentSource]); - - const handleSourceSelect = useCallback((source: MetadataSource) => { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - setSelectedSource(source.id); - setIsVisible(false); - onSourceChange(source.id, source.type); - }, [onSourceChange]); - - const handleOpenModal = useCallback(() => { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - // Reset animation values - scaleAnim.setValue(0.8); - opacityAnim.setValue(0); - setIsVisible(true); - Animated.parallel([ - Animated.spring(scaleAnim, { - toValue: 1, - useNativeDriver: true, - tension: 100, - friction: 8, - }), - Animated.timing(opacityAnim, { - toValue: 1, - duration: 200, - useNativeDriver: true, - }), - ]).start(); - }, [scaleAnim, opacityAnim]); - - const handleCloseModal = useCallback(() => { - Animated.parallel([ - Animated.spring(scaleAnim, { - toValue: 0.8, - useNativeDriver: true, - tension: 100, - friction: 8, - }), - Animated.timing(opacityAnim, { - toValue: 0, - duration: 150, - useNativeDriver: true, - }), - ]).start(() => { - setIsVisible(false); - }); - }, [scaleAnim, opacityAnim]); - - const currentSourceName = useMemo(() => { - const source = sources.find(s => s.id === selectedSource); - return source?.name || 'Auto (Best Available)'; - }, [sources, selectedSource]); - - const getSourceIcon = useCallback((source: MetadataSource) => { - switch (source.icon) { - case 'auto-fix-high': - return 'auto-fix-high'; - case 'movie': - return 'movie'; - case 'extension': - return 'extension'; - default: - return 'info'; - } - }, []); - - // Always show if we have sources and onSourceChange callback - if (sources.length === 0 || !onSourceChange) { - console.log('[MetadataSourceSelector] Not showing selector:', { sourcesLength: sources.length, hasCallback: !!onSourceChange }); - return null; - } - - console.log('[MetadataSourceSelector] Showing selector with sources:', sources.map(s => s.name)); - - return ( - <> - - - Metadata Source - - !disabled && handleOpenModal()} - disabled={disabled} - activeOpacity={0.7} - > - - s.id === selectedSource) || sources[0])} - size={20} - color={currentTheme.colors.text} - /> - - {currentSourceName} - - - - - - - - - - - - Select Metadata Source - - - - - - - - - {loading ? ( - - - - Loading sources... - - - ) : ( - - {sources.map((source) => ( - handleSourceSelect(source)} - activeOpacity={0.7} - > - - - - - - - {source.name} - - {source.type === 'addon' && source.hasMetaSupport && ( - - Stremio Addon with metadata support - - )} - {source.type === 'tmdb' && ( - - Comprehensive movie and TV database - - )} - {source.id === 'auto' && ( - - Automatically selects the best available source - - )} - - - {selectedSource === source.id && ( - - - - )} - - ))} - - )} - - - - - ); -}; - -const styles = StyleSheet.create({ - container: { - marginBottom: 16, - }, - label: { - fontSize: 14, - fontWeight: '600', - marginBottom: 8, - }, - selector: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 16, - paddingVertical: 12, - borderRadius: 12, - borderWidth: 1, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - }, - disabled: { - opacity: 0.5, - }, - selectorContent: { - flexDirection: 'row', - alignItems: 'center', - flex: 1, - }, - selectorText: { - fontSize: 14, - fontWeight: '500', - marginLeft: 8, - flex: 1, - }, - modalOverlay: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.8)', - justifyContent: 'center', - alignItems: 'center', - padding: 20, - }, - modalContent: { - width: '90%', - maxWidth: 380, - maxHeight: '75%', - borderRadius: 16, - overflow: 'hidden', - shadowColor: '#000', - shadowOffset: { width: 0, height: 10 }, - shadowOpacity: 0.4, - shadowRadius: 20, - elevation: 10, - }, - modalHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - padding: 20, - borderBottomWidth: 1, - borderBottomColor: 'rgba(255, 255, 255, 0.15)', - }, - modalTitle: { - fontSize: 18, - fontWeight: '700', - letterSpacing: 0.3, - }, - closeButton: { - padding: 8, - borderRadius: 20, - backgroundColor: 'rgba(255, 255, 255, 0.15)', - }, - loadingContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - padding: 40, - }, - loadingText: { - marginLeft: 12, - fontSize: 14, - fontWeight: '500', - }, - sourcesList: { - maxHeight: 350, - }, - sourceItem: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - padding: 16, - marginHorizontal: 12, - marginVertical: 4, - borderRadius: 12, - borderWidth: 1, - borderColor: 'transparent', - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.1, - shadowRadius: 2, - elevation: 1, - }, - sourceContent: { - flexDirection: 'row', - alignItems: 'center', - flex: 1, - }, - sourceInfo: { - marginLeft: 16, - flex: 1, - }, - sourceName: { - fontSize: 16, - fontWeight: '600', - marginBottom: 4, - letterSpacing: 0.2, - }, - sourceDescription: { - fontSize: 13, - lineHeight: 18, - opacity: 0.8, - }, - iconContainer: { - width: 44, - height: 44, - borderRadius: 22, - justifyContent: 'center', - alignItems: 'center', - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.1, - shadowRadius: 2, - elevation: 1, - }, - checkContainer: { - padding: 4, - }, -}); - -export default MetadataSourceSelector; \ No newline at end of file diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index f2c863c7..431c273b 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -46,6 +46,8 @@ export const SeriesContent: React.FC = ({ 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 refs for the scroll views const seasonScrollViewRef = useRef(null); @@ -162,6 +164,50 @@ export const SeriesContent: React.FC = ({ 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?.[String(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); @@ -351,6 +397,13 @@ export const SeriesContent: React.FC = ({ // 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; @@ -415,7 +468,7 @@ export const SeriesContent: React.FC = ({ styles.episodeMetadata, isTablet && styles.episodeMetadataTablet ]}> - {episode.vote_average > 0 && ( + {effectiveVote > 0 && ( = ({ contentFit="contain" /> - {episode.vote_average.toFixed(1)} + {effectiveVote.toFixed(1)} )} - {episode.runtime && ( + {effectiveRuntime && ( - {formatRuntime(episode.runtime)} + {formatRuntime(effectiveRuntime)} )} @@ -853,10 +906,7 @@ const styles = StyleSheet.create({ ratingContainer: { flexDirection: 'row', alignItems: 'center', - backgroundColor: 'rgba(0,0,0,0.7)', - paddingHorizontal: 4, - paddingVertical: 2, - borderRadius: 4, + // chip background removed }, tmdbLogo: { width: 20, @@ -871,10 +921,7 @@ const styles = StyleSheet.create({ runtimeContainer: { flexDirection: 'row', alignItems: 'center', - backgroundColor: 'rgba(0,0,0,0.7)', - paddingHorizontal: 4, - paddingVertical: 2, - borderRadius: 4, + // chip background removed }, runtimeText: { fontSize: 13, @@ -1049,10 +1096,7 @@ const styles = StyleSheet.create({ runtimeContainerHorizontal: { flexDirection: 'row', alignItems: 'center', - backgroundColor: 'rgba(0,0,0,0.4)', - paddingHorizontal: 5, - paddingVertical: 2, - borderRadius: 3, + // chip background removed }, runtimeTextHorizontal: { color: 'rgba(255,255,255,0.8)', @@ -1062,10 +1106,7 @@ const styles = StyleSheet.create({ ratingContainerHorizontal: { flexDirection: 'row', alignItems: 'center', - backgroundColor: 'rgba(0,0,0,0.4)', - paddingHorizontal: 5, - paddingVertical: 2, - borderRadius: 3, + // chip background removed gap: 2, }, ratingTextHorizontal: { diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index c60ad0ca..2fa98d8e 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -78,8 +78,7 @@ const MetadataScreen: React.FC = () => { const [selectedCastMember, setSelectedCastMember] = useState(null); const [shouldLoadSecondaryData, setShouldLoadSecondaryData] = useState(false); const [isScreenFocused, setIsScreenFocused] = useState(true); - const [currentMetadataSource, setCurrentMetadataSource] = useState(addonId || 'auto'); - const [loadingMetadataSource, setLoadingMetadataSource] = useState(false); + // Source switching removed const transitionOpacity = useSharedValue(1); const interactionComplete = useRef(false); @@ -467,70 +466,7 @@ const MetadataScreen: React.FC = () => { setShowCastModal(true); }, [isScreenFocused]); - const handleMetadataSourceChange = useCallback(async (sourceId: string, sourceType: 'addon' | 'tmdb') => { - if (!isScreenFocused) return; - - setCurrentMetadataSource(sourceId); - setLoadingMetadataSource(true); - - // Reload metadata with the new source - try { - let newMetadata = null; - - if (sourceType === 'tmdb') { - // Load from TMDB - if (id.startsWith('tt')) { - // Convert IMDB ID to TMDB ID first - const tmdbId = await tmdbService.findTMDBIdByIMDB(id); - if (tmdbId) { - if (type === 'movie') { - const movieDetails = await tmdbService.getMovieDetails(tmdbId.toString()); - if (movieDetails) { - newMetadata = { - id: id, - type: 'movie', - name: movieDetails.title, - poster: tmdbService.getImageUrl(movieDetails.poster_path) || '', - banner: tmdbService.getImageUrl(movieDetails.backdrop_path) || '', - description: movieDetails.overview || '', - year: movieDetails.release_date ? parseInt(movieDetails.release_date.substring(0, 4)) : undefined, - genres: movieDetails.genres?.map((g: { name: string }) => g.name) || [], - inLibrary: metadata?.inLibrary || false, - }; - } - } else if (type === 'series') { - const showDetails = await tmdbService.getTVShowDetails(tmdbId); - if (showDetails) { - newMetadata = { - id: id, - type: 'series', - name: showDetails.name, - poster: tmdbService.getImageUrl(showDetails.poster_path) || '', - banner: tmdbService.getImageUrl(showDetails.backdrop_path) || '', - description: showDetails.overview || '', - year: showDetails.first_air_date ? parseInt(showDetails.first_air_date.substring(0, 4)) : undefined, - genres: showDetails.genres?.map((g: { name: string }) => g.name) || [], - inLibrary: metadata?.inLibrary || false, - }; - } - } - } - } - } else { - // Load from addon or auto - const addonIdToUse = sourceId === 'auto' ? undefined : sourceId; - newMetadata = await catalogService.getEnhancedContentDetails(type, id, addonIdToUse); - } - - if (newMetadata) { - setMetadata(newMetadata); - } - } catch (error) { - console.error('[MetadataScreen] Failed to reload metadata with new source:', error); - } finally { - setLoadingMetadataSource(false); - } - }, [isScreenFocused, id, type, metadata, tmdbService, catalogService, setMetadata]); + // Source switching removed // Ultra-optimized animated styles - minimal calculations with conditional updates const containerStyle = useAnimatedStyle(() => ({ @@ -659,9 +595,7 @@ const MetadataScreen: React.FC = () => { imdbId={imdbId} type={type as 'movie' | 'series'} contentId={id} - currentMetadataSource={currentMetadataSource} - onMetadataSourceChange={handleMetadataSourceChange} - loadingMetadata={loadingMetadataSource} + loadingMetadata={false} renderRatings={() => imdbId && shouldLoadSecondaryData ? ( ) : null} diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 9e1e10d4..0f330f5d 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -846,6 +846,46 @@ export const StreamsScreen = () => { ); }, [selectedEpisode, groupedEpisodes, id]); + // TMDB hydration for series hero (rating/runtime/still) + const [tmdbEpisodeOverride, setTmdbEpisodeOverride] = useState<{ vote_average?: number; runtime?: number; still_path?: string } | null>(null); + + useEffect(() => { + const hydrateEpisodeFromTmdb = async () => { + try { + setTmdbEpisodeOverride(null); + if (type !== 'series' || !currentEpisode || !id) return; + // Skip if data already present + const needsHydration = !(currentEpisode as any).runtime || !(currentEpisode as any).vote_average || !currentEpisode.still_path; + if (!needsHydration) return; + + // Resolve TMDB show id + let tmdbShowId: number | null = null; + if (id.startsWith('tmdb:')) { + tmdbShowId = parseInt(id.split(':')[1], 10); + } else if (id.startsWith('tt')) { + tmdbShowId = await tmdbService.findTMDBIdByIMDB(id); + } + if (!tmdbShowId) return; + + const allEpisodes: Record = await tmdbService.getAllEpisodes(tmdbShowId) as any; + const seasonKey = String(currentEpisode.season_number); + const seasonList: any[] = (allEpisodes && (allEpisodes as any)[seasonKey]) || []; + const ep = seasonList.find((e: any) => e.episode_number === currentEpisode.episode_number); + if (ep) { + setTmdbEpisodeOverride({ + vote_average: ep.vote_average, + runtime: ep.runtime, + still_path: ep.still_path, + }); + } + } catch (e) { + logger.warn('[StreamsScreen] TMDB hydration failed:', e); + } + }; + + hydrateEpisodeFromTmdb(); + }, [type, id, currentEpisode?.season_number, currentEpisode?.episode_number]); + const navigateToPlayer = useCallback(async (stream: Stream, options?: { forceVlc?: boolean; headers?: Record }) => { // Prepare available streams for the change source feature const streamsToPass = type === 'series' ? episodeStreams : groupedStreams; @@ -1336,14 +1376,29 @@ export const StreamsScreen = () => { return tmdbService.getImageUrl(episodeThumbnail, 'original'); } if (!currentEpisode) return null; - if (currentEpisode.still_path) { + const hydratedStill = tmdbEpisodeOverride?.still_path; + if (currentEpisode.still_path || hydratedStill) { if (currentEpisode.still_path.startsWith('http')) { return currentEpisode.still_path; } - return tmdbService.getImageUrl(currentEpisode.still_path, 'original'); + const path = currentEpisode.still_path || hydratedStill || ''; + return tmdbService.getImageUrl(path, 'original'); } return metadata?.poster || null; - }, [currentEpisode, metadata, episodeThumbnail]); + }, [currentEpisode, metadata, episodeThumbnail, tmdbEpisodeOverride?.still_path]); + + // Effective TMDB fields for hero (series) + const effectiveEpisodeVote = useMemo(() => { + if (!currentEpisode) return 0; + const v = (tmdbEpisodeOverride?.vote_average ?? currentEpisode.vote_average) || 0; + return typeof v === 'number' ? v : Number(v) || 0; + }, [currentEpisode, tmdbEpisodeOverride?.vote_average]); + + const effectiveEpisodeRuntime = useMemo(() => { + if (!currentEpisode) return undefined as number | undefined; + const r = (tmdbEpisodeOverride?.runtime ?? (currentEpisode as any).runtime) as number | undefined; + return r; + }, [currentEpisode, tmdbEpisodeOverride?.runtime]); // Prefetch hero/backdrop and title logo when StreamsScreen opens useEffect(() => { @@ -1508,33 +1563,35 @@ export const StreamsScreen = () => { {currentEpisode.name} {!!currentEpisode.overview && ( - - {currentEpisode.overview} - + + + {currentEpisode.overview} + + )} - + {tmdbService.formatAirDate(currentEpisode.air_date)} - {currentEpisode.vote_average > 0 && ( - + {effectiveEpisodeVote > 0 && ( + - {currentEpisode.vote_average.toFixed(1)} + {effectiveEpisodeVote.toFixed(1)} - + )} - {!!currentEpisode.runtime && ( - + {!!effectiveEpisodeRuntime && ( + - {currentEpisode.runtime >= 60 - ? `${Math.floor(currentEpisode.runtime / 60)}h ${currentEpisode.runtime % 60}m` - : `${currentEpisode.runtime}m`} + {effectiveEpisodeRuntime >= 60 + ? `${Math.floor(effectiveEpisodeRuntime / 60)}h ${effectiveEpisodeRuntime % 60}m` + : `${effectiveEpisodeRuntime}m`} - + )} - + ) : ( // Placeholder to reserve space and avoid layout shift while loading @@ -1978,10 +2035,7 @@ const createStyles = (colors: any) => StyleSheet.create({ streamsHeroRating: { flexDirection: 'row', alignItems: 'center', - backgroundColor: 'rgba(0,0,0,0.7)', - paddingHorizontal: 6, - paddingVertical: 3, - borderRadius: 4, + // chip background removed marginTop: 0, }, tmdbLogo: { @@ -1989,7 +2043,7 @@ const createStyles = (colors: any) => StyleSheet.create({ height: 14, }, streamsHeroRatingText: { - color: colors.accent, + color: colors.highEmphasis, fontSize: 13, fontWeight: '700', marginLeft: 4, @@ -2070,10 +2124,7 @@ const createStyles = (colors: any) => StyleSheet.create({ flexDirection: 'row', alignItems: 'center', gap: 4, - backgroundColor: 'rgba(0,0,0,0.5)', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 6, + // chip background removed }, streamsHeroRuntimeText: { color: colors.mediumEmphasis,